From 860b40bf1e901c17ee76f601040710b2e1288ac9 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 19 May 2026 18:40:20 +0800 Subject: [PATCH 1/2] feat(core): limit background agent concurrency --- .../core/src/agents/background-tasks.test.ts | 84 +++++++++++++ packages/core/src/agents/background-tasks.ts | 56 +++++++++ packages/core/src/tools/agent/agent.test.ts | 69 ++++++++++ packages/core/src/tools/agent/agent.ts | 119 +++++++++++++----- 4 files changed, 295 insertions(+), 33 deletions(-) diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index ba61c895084..cc09578cbc4 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -6,12 +6,33 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { + BACKGROUND_AGENT_CONCURRENCY_ENV, BackgroundTaskRegistry, + DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS, + MAX_CONCURRENT_BACKGROUND_AGENTS, MAX_RETAINED_TERMINAL_AGENTS, + resolveMaxConcurrentBackgroundAgents, + type AgentTaskRegistration, type BackgroundTaskEntry, } from './background-tasks.js'; import * as transcript from './agent-transcript.js'; +function makeRegistration( + agentId: string, + overrides: Partial = {}, +): AgentTaskRegistration { + return { + agentId, + description: agentId, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + isBackgrounded: true, + outputFile: `/tmp/${agentId}.jsonl`, + ...overrides, + }; +} + describe('BackgroundTaskRegistry', () => { let registry: BackgroundTaskRegistry; @@ -343,6 +364,69 @@ describe('BackgroundTaskRegistry', () => { expect(running[0].agentId).toBe('b'); }); + describe('background concurrency limit', () => { + it('resolves the default and env override for the background agent cap', () => { + expect(resolveMaxConcurrentBackgroundAgents({})).toBe( + DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS, + ); + expect( + resolveMaxConcurrentBackgroundAgents({ + [BACKGROUND_AGENT_CONCURRENCY_ENV]: '3', + }), + ).toBe(3); + expect( + resolveMaxConcurrentBackgroundAgents({ + [BACKGROUND_AGENT_CONCURRENCY_ENV]: '0', + }), + ).toBe(DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS); + expect(MAX_CONCURRENT_BACKGROUND_AGENTS).toBeGreaterThanOrEqual(1); + }); + + it('rejects new running background agents once the cap is reached', () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 2, + }); + + registry.register(makeRegistration('bg-1')); + registry.register(makeRegistration('bg-2')); + + expect(() => registry.register(makeRegistration('bg-3'))).toThrow( + 'Cannot start background agent: maximum concurrent background agents ' + + '(2) reached. Stop an existing agent first.', + ); + expect(registry.get('bg-3')).toBeUndefined(); + }); + + it('does not count foreground, paused, or terminal entries toward the cap', () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + + registry.register( + makeRegistration('fg-1', { + isBackgrounded: false, + }), + ); + registry.register( + makeRegistration('paused-1', { + status: 'paused', + }), + ); + + registry.register(makeRegistration('bg-1')); + expect(() => registry.register(makeRegistration('bg-2'))).toThrow( + 'maximum concurrent background agents (1) reached', + ); + + registry.complete('bg-1', 'done'); + registry.register(makeRegistration('bg-2')); + + expect(registry.get('fg-1')).toBeDefined(); + expect(registry.get('paused-1')).toBeDefined(); + expect(registry.get('bg-2')?.status).toBe('running'); + }); + }); + it('aborts all running agents and emits fallback notifications', () => { const callback = vi.fn(); registry.setNotificationCallback(callback); diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 115d2c258e9..f985b2b65b9 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -31,6 +31,26 @@ const debugLogger = createDebugLogger('BACKGROUND_TASKS'); const MAX_DESCRIPTION_LENGTH = 40; const MAX_RECENT_ACTIVITIES = 5; +export const DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS = 10; +export const BACKGROUND_AGENT_CONCURRENCY_ENV = + 'QWEN_CODE_MAX_BACKGROUND_AGENTS'; + +export function resolveMaxConcurrentBackgroundAgents( + env: Record = process.env, +): number { + const raw = env[BACKGROUND_AGENT_CONCURRENCY_ENV]; + if (raw === undefined || raw.trim() === '') { + return DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS; + } + + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 + ? parsed + : DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS; +} + +export const MAX_CONCURRENT_BACKGROUND_AGENTS = + resolveMaxConcurrentBackgroundAgents(); /** * Cap on how many fully-finalized terminal entries (those that have @@ -254,15 +274,45 @@ export type BackgroundActivityChangeCallback = (entry: AgentTask) => void; type MessageWaiter = () => void; +export interface BackgroundTaskRegistryOptions { + maxConcurrentBackgroundAgents?: number; +} + export class BackgroundTaskRegistry { private readonly agents = new Map(); private readonly messageWaiters = new Map>(); + private readonly maxConcurrentBackgroundAgents: number; private notificationCallback?: BackgroundNotificationCallback; private registerCallback?: BackgroundRegisterCallback; private statusChangeCallback?: BackgroundStatusChangeCallback; private activityChangeCallback?: BackgroundActivityChangeCallback; + constructor(options: BackgroundTaskRegistryOptions = {}) { + const configured = + options.maxConcurrentBackgroundAgents ?? MAX_CONCURRENT_BACKGROUND_AGENTS; + this.maxConcurrentBackgroundAgents = + Number.isInteger(configured) && configured >= 1 + ? configured + : MAX_CONCURRENT_BACKGROUND_AGENTS; + } + + assertCanStartBackgroundAgent(): void { + if ( + this.getRunningBackgroundCount() >= this.maxConcurrentBackgroundAgents + ) { + throw new Error( + `Cannot start background agent: maximum concurrent background agents ` + + `(${this.maxConcurrentBackgroundAgents}) reached. Stop an existing ` + + `agent first.`, + ); + } + } + register(registration: AgentTaskRegistration): AgentTask { + if (registration.isBackgrounded && registration.status === 'running') { + this.assertCanStartBackgroundAgent(); + } + // Mutate the registration in place to graduate it to an `AgentTask`. // Returning the same reference lets callers (e.g. the resume service) // continue using their local variable post-register and lets external @@ -507,6 +557,12 @@ export class BackgroundTaskRegistry { return Array.from(this.agents.values()); } + private getRunningBackgroundCount(): number { + return Array.from(this.agents.values()).filter( + (entry) => entry.isBackgrounded && entry.status === 'running', + ).length; + } + /** * True if any registered task has not yet emitted its terminal * task-notification. Covers `running` (still executing) and diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index bcd9a789f11..9514f091764 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -98,6 +98,7 @@ describe('AgentTool', () => { // to surface the run in the pill+dialog. A no-op stub registry is // enough for these tests — they don't assert on registry behavior. const stubRegistry = { + assertCanStartBackgroundAgent: vi.fn(), register: vi.fn(), unregisterForeground: vi.fn(), complete: vi.fn(), @@ -1757,6 +1758,7 @@ describe('AgentTool', () => { let mockAgent: AgentHeadless; let mockContextState: ContextState; let mockRegistry: { + assertCanStartBackgroundAgent: ReturnType; register: ReturnType; unregisterForeground: ReturnType; complete: ReturnType; @@ -1797,6 +1799,7 @@ describe('AgentTool', () => { MockedContextState.mockImplementation(() => mockContextState); mockRegistry = { + assertCanStartBackgroundAgent: vi.fn(), register: vi.fn(), unregisterForeground: vi.fn(), complete: vi.fn(), @@ -1984,6 +1987,72 @@ describe('AgentTool', () => { expect(mockRegistry.register).toHaveBeenCalled(); }); + it('returns registry registration errors to the model without launching the background body', async () => { + const errorMessage = + 'Cannot start background agent: maximum concurrent background agents ' + + '(1) reached. Stop an existing agent first.'; + mockRegistry.register.mockImplementation(() => { + throw new Error(errorMessage); + }); + const attachSpy = vi.spyOn(transcript, 'attachJsonlTranscriptWriter'); + + try { + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + expect(partToString(result.llmContent)).toBe(errorMessage); + expect((result.returnDisplay as AgentResultDisplay).status).toBe( + 'failed', + ); + expect(attachSpy).not.toHaveBeenCalled(); + expect(mockAgent.execute).not.toHaveBeenCalled(); + expect(mockRegistry.complete).not.toHaveBeenCalled(); + expect(mockRegistry.fail).not.toHaveBeenCalled(); + } finally { + attachSpy.mockRestore(); + } + }); + + 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); + }); + const mockHookSystem = { + fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), + fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), + } as unknown as HookSystem; + (config as unknown as Record)['getHookSystem'] = vi + .fn() + .mockReturnValue(mockHookSystem); + + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + expect(partToString(result.llmContent)).toBe(errorMessage); + expect(mockHookSystem.fireSubagentStartEvent).not.toHaveBeenCalled(); + expect(mockSubagentManager.createAgentHeadless).not.toHaveBeenCalled(); + expect(mockRegistry.register).not.toHaveBeenCalled(); + }); + it('passes the sidechain transcript path to SubagentStop hooks for fresh background agents', async () => { const mockHookSystem = { fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index f7cd9114fd1..b1f9c00d43f 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1338,6 +1338,33 @@ class AgentToolInvocation extends BaseToolInvocation { updateOutput(this.currentDisplay); } + // OR the tool parameter with the agent definition's background flag. + const shouldRunInBackground = + this.params.run_in_background === true || + subagentConfig.background === true; + + if (shouldRunInBackground) { + try { + this.config + .getBackgroundTaskRegistry() + .assertCanStartBackgroundAgent(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.updateDisplay( + { + status: 'failed', + terminateReason: errorMessage, + }, + updateOutput, + ); + return { + llmContent: errorMessage, + returnDisplay: this.currentDisplay!, + }; + } + } + // ── Optional worktree isolation (Phase 1: provision) ────────── // Provision the worktree BEFORE creating the agent Config so the // override below can rebind `getTargetDir()` to the worktree path @@ -1534,6 +1561,17 @@ class AgentToolInvocation extends BaseToolInvocation { ov.getWorkspaceContext = () => wtWorkspace; } + // Date.now() alone collides when two parallel background agents of the + // same type land in the same ms; the registry is keyed by agentId. + const agentIdSuffix = this.callId ?? randomUUID().slice(0, 8); + const hookOpts = { + agentId: `${subagentConfig.name}-${agentIdSuffix}`, + agentType: this.params.subagent_type || subagentConfig.name, + resolvedMode, + signal, + updateOutput, + }; + // Create the subagent. Fork bypasses SubagentManager because its // runtime configs are synthesized from the parent's cache-safe params. let subagent: AgentHeadless; @@ -1575,23 +1613,7 @@ class AgentToolInvocation extends BaseToolInvocation { const contextState = new ContextState(); contextState.set('task_prompt', taskPrompt); - // Date.now() alone collides when two parallel background agents of the - // same type land in the same ms; the registry is keyed by agentId. - const agentIdSuffix = this.callId ?? randomUUID().slice(0, 8); - const hookOpts = { - agentId: `${subagentConfig.name}-${agentIdSuffix}`, - agentType: this.params.subagent_type || subagentConfig.name, - resolvedMode, - signal, - updateOutput, - }; - // ── Background (async) execution path ────────────────────── - // OR the tool parameter with the agent definition's background flag. - const shouldRunInBackground = - this.params.run_in_background === true || - subagentConfig.background === true; - if (shouldRunInBackground) { // Fire SubagentStart hook before background launch const hookSystem = this.config.getHookSystem(); @@ -1673,6 +1695,54 @@ class AgentToolInvocation extends BaseToolInvocation { hookOpts.agentId, ); const projectRoot = this.config.getProjectRoot(); + try { + // Register before writing the meta sidecar — see the matching + // 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, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + bgAbortController.abort(); + + let wtSuffix = ''; + try { + wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); + } catch (cleanupError) { + debugLogger.warn( + `[Agent] Worktree cleanup after background registration failure failed: ${cleanupError}`, + ); + } + + this.updateDisplay( + { + status: 'failed', + terminateReason: errorMessage, + }, + updateOutput, + ); + void agentConfig + .getToolRegistry() + .stop() + .catch(() => {}); + return { + llmContent: `${errorMessage}${wtSuffix}`, + returnDisplay: this.currentDisplay!, + }; + } const { cleanup: cleanupJsonl } = attachJsonlTranscriptWriter( bgEventEmitter, jsonlPath, @@ -1697,23 +1767,6 @@ class AgentToolInvocation extends BaseToolInvocation { launchTaskPrompt: isFork ? bgTaskPrompt : undefined, }, ); - // Register before writing the meta sidecar — see the matching - // foreground call below for the full rationale. Keeping the - // order symmetric here guards the background path against the - // same orphaned-meta hazard if register() ever grows a throw. - 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, - }); writeAgentMeta(metaPath, { agentId: hookOpts.agentId, agentType: hookOpts.agentType, From 0f8a8d43f2851ed443d5cd44e7223484d82b5de9 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 21 May 2026 10:36:33 +0800 Subject: [PATCH 2/2] fix(core): handle background agent cap on resume --- .../agents/background-agent-resume.test.ts | 138 ++++++++++++++++++ .../src/agents/background-agent-resume.ts | 38 +++-- .../core/src/agents/background-tasks.test.ts | 17 +++ packages/core/src/agents/background-tasks.ts | 29 +++- packages/core/src/tools/agent/agent.test.ts | 42 ++++++ packages/core/src/tools/agent/agent.ts | 30 +++- 6 files changed, 274 insertions(+), 20 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 73dfe9c87c7..4899a2f2961 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -455,6 +455,144 @@ describe('BackgroundAgentResumeService', () => { }); }); + it('can resume into the final background concurrency slot', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + const sessionId = 'session-resume-cap'; + const agentId = 'agent-resume-cap'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume at cap', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + 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: 'Resume at cap' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume at cap', + subagentType: 'researcher', + isBackgrounded: true, + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume at cap', + outputFile, + metaPath, + }); + + const subagent = { + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeDefined(); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('completed'); + }); + expect(subagent.execute).toHaveBeenCalledTimes(1); + }); + + it('keeps a paused agent paused when resume cannot claim a background slot', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + const sessionId = 'session-resume-full'; + const agentId = 'agent-resume-full'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + registry.register({ + agentId: 'already-running', + description: 'Already running', + subagentType: 'researcher', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile: path.join(tempDir, 'already-running.jsonl'), + }); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume while full', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + 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: 'Resume while full' }] }, + }) + '\n', + 'utf8', + ); + registry.register({ + agentId, + description: 'Resume while full', + subagentType: 'researcher', + isBackgrounded: true, + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume while full', + outputFile, + metaPath, + }); + + const { service, subagentManager } = createService(); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + 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(); + }); + it('passes the sidechain transcript path to SubagentStop hooks on resume', async () => { const sessionId = 'session-stop-hook'; const agentId = 'agent-stop-hook'; diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 640ce02428f..f2558c4ce44 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -487,18 +487,32 @@ export class BackgroundAgentResumeService { const bgAbortController = new AbortController(); - registry.register({ - ...existing, - status: 'running', - abortController: bgAbortController, - endTime: undefined, - result: undefined, - error: undefined, - resumeBlockedReason: undefined, - stats: undefined, - recentActivities: [], - pendingMessages: [...(existing.pendingMessages ?? [])], - }); + try { + registry.register({ + ...existing, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + resumeBlockedReason: undefined, + stats: undefined, + recentActivities: [], + pendingMessages: [...(existing.pendingMessages ?? [])], + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.warn( + `[BackgroundAgentResume] Cannot resume background agent ${agentId}: ${errorMessage}`, + ); + patchAgentMeta(metaPath, { + lastError: errorMessage, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, { error: errorMessage }); + return undefined; + } let cleanupOwnedMonitorNotifications: (() => void) | undefined; let cleanupJsonl: (() => void) | undefined; diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index cc09578cbc4..fbb7319ba12 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -397,6 +397,23 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-3')).toBeUndefined(); }); + it('allows replacing the same running background agent at the cap', () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + + registry.register(makeRegistration('bg-1')); + + expect(() => + registry.register( + makeRegistration('bg-1', { + prompt: 'resumed continuation', + }), + ), + ).not.toThrow(); + expect(registry.get('bg-1')?.prompt).toBe('resumed continuation'); + }); + it('does not count foreground, paused, or terminal entries toward the cap', () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index f985b2b65b9..5127301bbfb 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -44,9 +44,15 @@ export function resolveMaxConcurrentBackgroundAgents( } const parsed = Number(raw); - return Number.isInteger(parsed) && parsed >= 1 - ? parsed - : DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS; + if (!Number.isInteger(parsed) || parsed < 1) { + debugLogger.warn( + `Invalid ${BACKGROUND_AGENT_CONCURRENCY_ENV}=${JSON.stringify(raw)}, ` + + `using default (${DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS})`, + ); + return DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS; + } + + return parsed; } export const MAX_CONCURRENT_BACKGROUND_AGENTS = @@ -297,9 +303,13 @@ export class BackgroundTaskRegistry { } assertCanStartBackgroundAgent(): void { - if ( - this.getRunningBackgroundCount() >= this.maxConcurrentBackgroundAgents - ) { + const running = this.getRunningBackgroundCount(); + if (running >= this.maxConcurrentBackgroundAgents) { + debugLogger.warn( + `Background agent concurrency cap reached: ` + + `${running}/${this.maxConcurrentBackgroundAgents}. ` + + `Refusing new background agent.`, + ); throw new Error( `Cannot start background agent: maximum concurrent background agents ` + `(${this.maxConcurrentBackgroundAgents}) reached. Stop an existing ` + @@ -310,7 +320,12 @@ export class BackgroundTaskRegistry { register(registration: AgentTaskRegistration): AgentTask { if (registration.isBackgrounded && registration.status === 'running') { - this.assertCanStartBackgroundAgent(); + const existing = this.agents.get(registration.agentId); + const isReplacingRunning = + existing?.isBackgrounded === true && existing.status === 'running'; + if (!isReplacingRunning) { + this.assertCanStartBackgroundAgent(); + } } // Mutate the registration in place to graduate it to an `AgentTask`. diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 9514f091764..05153eee119 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -2021,6 +2021,48 @@ describe('AgentTool', () => { } }); + it('fires SubagentStop when the final background register check fails after SubagentStart', async () => { + const errorMessage = + 'Cannot start background agent: maximum concurrent background agents ' + + '(1) reached. Stop an existing agent first.'; + mockRegistry.register.mockImplementation(() => { + throw new Error(errorMessage); + }); + const mockHookSystem = { + fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), + fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), + } as unknown as HookSystem; + (config as unknown as Record)['getHookSystem'] = vi + .fn() + .mockReturnValue(mockHookSystem); + + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + expect(partToString(result.llmContent)).toBe(errorMessage); + expect(mockHookSystem.fireSubagentStartEvent).toHaveBeenCalledOnce(); + expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenCalledWith( + expect.stringContaining('monitor-'), + 'monitor', + expect.stringMatching( + /subagents[\\/]test-session-id[\\/]agent-monitor-.*\.jsonl$/, + ), + 'Monitor done', + false, + PermissionMode.AutoEdit, + undefined, + ); + 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 ' + diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index b1f9c00d43f..4f11ca24f75 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1343,6 +1343,10 @@ class AgentToolInvocation extends BaseToolInvocation { this.params.run_in_background === true || subagentConfig.background === true; + // 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. if (shouldRunInBackground) { try { this.config @@ -1617,6 +1621,7 @@ class AgentToolInvocation extends BaseToolInvocation { if (shouldRunInBackground) { // Fire SubagentStart hook before background launch const hookSystem = this.config.getHookSystem(); + let subagentStartHookCompleted = false; if (hookSystem) { try { const startHookOutput = await hookSystem.fireSubagentStartEvent( @@ -1629,6 +1634,7 @@ class AgentToolInvocation extends BaseToolInvocation { if (additionalContext) { contextState.set('hook_context', additionalContext); } + subagentStartHookCompleted = true; } catch (hookError) { debugLogger.warn( `[Agent] SubagentStart hook failed, continuing execution: ${hookError}`, @@ -1718,6 +1724,24 @@ class AgentToolInvocation extends BaseToolInvocation { error instanceof Error ? error.message : String(error); bgAbortController.abort(); + if (hookSystem && subagentStartHookCompleted) { + try { + await hookSystem.fireSubagentStopEvent( + hookOpts.agentId, + hookOpts.agentType, + jsonlPath, + bgSubagent.getFinalText(), + false, + resolvedMode, + signal, + ); + } catch (hookError) { + debugLogger.warn( + `[Agent] SubagentStop hook after background registration failure failed: ${hookError}`, + ); + } + } + let wtSuffix = ''; try { wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); @@ -1737,7 +1761,11 @@ class AgentToolInvocation extends BaseToolInvocation { void agentConfig .getToolRegistry() .stop() - .catch(() => {}); + .catch((stopError) => { + debugLogger.warn( + `[Agent] ToolRegistry stop after background registration failure failed: ${stopError}`, + ); + }); return { llmContent: `${errorMessage}${wtSuffix}`, returnDisplay: this.currentDisplay!,