Skip to content
11 changes: 11 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,7 @@ export async function loadCliConfig(
},
agents: settings.agents
? {
maxParallelAgents: settings.agents.maxParallelAgents,
displayMode: settings.agents.displayMode,
arena: settings.agents.arena
? {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
222 changes: 215 additions & 7 deletions packages/core/src/agents/background-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,24 +511,26 @@ describe('BackgroundTaskRegistry', () => {
expect(registry.get('bg-1')?.prompt).toBe('resumed continuation');
Comment thread
yiliang114 marked this conversation as resolved.
});

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',
Expand All @@ -546,6 +548,212 @@ 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('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,
});
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('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,
});
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('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', () => {
Expand Down
Loading
Loading