fix: settle Pi child capacity reservations

This commit is contained in:
2026-08-23 13:34:41 +08:00
parent 3de85d61d7
commit a10b98e484
5 changed files with 275 additions and 33 deletions

View File

@@ -8,7 +8,7 @@ import {
type PiSubagentChild,
type PiSubagentChildOpenInput,
} from '../../electron/coding-runtime/pi/subagent';
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
import { PiProcessBudget, PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool';
function parent(runId = 'run-a') {
return {
@@ -251,4 +251,140 @@ describe('Pi subagent scheduler', () => {
expect(processBudget.activeCount).toBe(0);
await scheduler.close();
});
it('reclaims a parent that becomes idle after child capacity is already reserved', async () => {
const parentOpenGate = deferred();
const processBudget = new PiProcessBudget(1);
let parentStopped = false;
let childRan = false;
const pool = new PiWorkerPool({
processBudget,
maxIdle: 1,
openWorker: async () => {
await parentOpenGate.promise;
return {
worker: {
id: 'delayed-parent',
generation: 1,
async request() {
return { type: 'response' as const, id: 'parent', success: true as const };
},
async send() {},
subscribe() { return () => undefined; },
subscribeInvalidation() { return () => undefined; },
async stop() {
parentStopped = true;
return { mode: 'stdin-close' as const, code: 0, signal: null };
},
},
session: { piSessionId: 'parent-session', sessionKey: 'parent-key' },
};
},
});
const preparingParent = pool.prepare({
conversationId: 'delayed-parent',
projectId: 'project-a',
agentId: 'agent-a',
title: 'Delayed parent',
model: {
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
},
});
await expect.poll(() => processBudget.activeCount).toBe(1);
const scheduler = new PiSubagentScheduler({
processBudget,
reclaimProcessCapacity: (signal) => pool.reclaimIdleWorker(signal),
openChild: async (input) => ({
id: input.taskId,
async run() {
childRan = true;
return { summary: 'done' };
},
async stop() {},
}),
});
const child = scheduler.dispatch({
...parent('delayed-idle'),
request: { mode: 'single', tasks: [task('agent-a')] },
});
await expect.poll(() => processBudget.waitingCount).toBe(1);
parentOpenGate.resolve();
await preparingParent;
await expect(child).resolves.toMatchObject({
details: { tasks: [{ status: 'complete' }] },
});
expect(parentStopped).toBe(true);
expect(childRan).toBe(true);
expect(processBudget.activeCount).toBe(0);
expect(processBudget.waitingCount).toBe(0);
await scheduler.close();
await pool.shutdown();
});
it('cancels a queued child reservation when the capacity reclaimer rejects', async () => {
const processBudget = new PiProcessBudget(1);
const parentLease = await processBudget.acquire();
const scheduler = new PiSubagentScheduler({
processBudget,
reclaimProcessCapacity: async () => {
throw new Error('reclaim failed');
},
openChild: async () => {
throw new Error('child must not open');
},
});
await expect(scheduler.dispatch({
...parent('reclaim-rejected'),
request: { mode: 'single', tasks: [task('agent-a')] },
})).resolves.toMatchObject({
details: { tasks: [{ status: 'error', errorCode: 'SUBAGENT_CHILD_FAILED' }] },
});
expect(processBudget.activeCount).toBe(1);
expect(processBudget.waitingCount).toBe(0);
parentLease.release();
expect(processBudget.activeCount).toBe(0);
expect(processBudget.waitingCount).toBe(0);
await scheduler.close();
});
it('cancels both capacity waits when the parent aborts before an idle worker exists', async () => {
const processBudget = new PiProcessBudget(1);
const parentLease = await processBudget.acquire();
let reclaimerCancelled = false;
const scheduler = new PiSubagentScheduler({
processBudget,
reclaimProcessCapacity: async (signal) => await new Promise<boolean>((_resolve, reject) => {
const cancel = () => {
reclaimerCancelled = true;
reject(new Error('reclaim cancelled'));
};
if (signal?.aborted) cancel();
else signal?.addEventListener('abort', cancel, { once: true });
}),
openChild: async () => {
throw new Error('child must not open');
},
});
const controller = new AbortController();
const child = scheduler.dispatch({
...parent('reclaim-aborted'),
request: { mode: 'single', tasks: [task('agent-a')] },
}, { signal: controller.signal });
await expect.poll(() => processBudget.waitingCount).toBe(1);
controller.abort();
await expect(child).resolves.toMatchObject({
details: { tasks: [{ status: 'aborted', errorCode: 'SUBAGENT_ABORTED' }] },
});
expect(reclaimerCancelled).toBe(true);
expect(processBudget.activeCount).toBe(1);
expect(processBudget.waitingCount).toBe(0);
parentLease.release();
expect(processBudget.activeCount).toBe(0);
await scheduler.close();
});
});

View File

@@ -227,6 +227,33 @@ describe('Pi worker pool', () => {
expect(processBudget.activeCount).toBe(0);
});
it('releases the process lease even when stopping an idle worker fails', async () => {
const processBudget = new PiProcessBudget(1);
const pool = new PiWorkerPool({
processBudget,
maxIdle: 1,
openWorker: async ({ conversation: input }) => {
const worker = new FakeWorker(`worker-${input.conversationId}`);
worker.stop = async () => {
worker.stopped = true;
throw new Error('stop failed');
};
return {
worker,
session: {
piSessionId: `session-${input.conversationId}`,
sessionKey: `key-${input.conversationId}`,
},
};
},
});
await pool.prepare(conversation('conversation-stop-failure'));
await expect(pool.dispose('conversation-stop-failure')).rejects.toThrow('stop failed');
expect(processBudget.activeCount).toBe(0);
expect(processBudget.waitingCount).toBe(0);
});
it('never evicts a running worker when the warm-idle LRU exceeds its cap', async () => {
const workers = new Map<string, FakeWorker>();
const pool = new PiWorkerPool({