fix: settle Pi child capacity reservations
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user