feat: add Pi subagent scheduler and child runtime
This commit is contained in:
@@ -6,6 +6,11 @@ import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
|
||||
import {
|
||||
PiSubagentChildError,
|
||||
PiSubagentScheduler,
|
||||
} from '../../electron/coding-runtime/pi/subagent';
|
||||
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
|
||||
|
||||
const roots: string[] = [];
|
||||
const hosts: PiManagedExtensionHost[] = [];
|
||||
@@ -81,7 +86,7 @@ describe('managed Pi extension bridge', () => {
|
||||
'utf8',
|
||||
)) as Record<string, unknown>;
|
||||
expect(context).toEqual({
|
||||
conversationId: 'conversation-a', workerGeneration: 2, runId: 'run-a',
|
||||
conversationId: 'conversation-a', workerGeneration: 2, role: 'parent', runId: 'run-a',
|
||||
});
|
||||
await first.dispose();
|
||||
const response = await post(replacement, {
|
||||
@@ -91,6 +96,117 @@ describe('managed Pi extension bridge', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('streams stable subagent details and rejects recursive child dispatch', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-subagent-'));
|
||||
roots.push(root);
|
||||
const scheduler = new PiSubagentScheduler({
|
||||
processBudget: new PiProcessBudget(8),
|
||||
openChild: async (input) => ({
|
||||
id: input.taskId,
|
||||
async run() { return { summary: `done ${input.agentId}` }; },
|
||||
async stop() {},
|
||||
}),
|
||||
});
|
||||
const host = new PiManagedExtensionHost();
|
||||
const tracked: Array<{ kind: string; id: string }> = [];
|
||||
let untracked = 0;
|
||||
host.configureSubagents({
|
||||
scheduler,
|
||||
trackGenerationResource: (input) => {
|
||||
tracked.push({ kind: input.kind, id: input.id });
|
||||
return () => { untracked += 1; };
|
||||
},
|
||||
});
|
||||
hosts.push(host);
|
||||
const parent = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||
});
|
||||
const child = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||
role: 'child', runId: 'run-a',
|
||||
});
|
||||
await host.bindRun('conversation-a', 1, 'run-a');
|
||||
const request = {
|
||||
action: 'subagent.dispatch', conversationId: 'conversation-a', workerGeneration: 1,
|
||||
runId: 'run-a', resourceId: 'subagent-tool',
|
||||
request: {
|
||||
mode: 'single',
|
||||
tasks: [{ agentId: 'agent-a', task: 'Inspect', toolProfile: 'read-only' }],
|
||||
},
|
||||
};
|
||||
const response = await post(parent, request);
|
||||
expect(response.status).toBe(200);
|
||||
const lines = (await response.text()).trim().split('\n').map((line) => JSON.parse(line));
|
||||
expect(lines.at(-1)).toMatchObject({
|
||||
done: true,
|
||||
details: {
|
||||
schema: 'subagent.v1', mode: 'single',
|
||||
tasks: [{ agentId: 'agent-a', status: 'complete', summary: 'done agent-a' }],
|
||||
},
|
||||
});
|
||||
expect(tracked).toEqual([{ kind: 'child', id: 'subagent-tool' }]);
|
||||
expect(untracked).toBe(1);
|
||||
expect((await post(child, request)).status).toBe(403);
|
||||
await scheduler.close();
|
||||
});
|
||||
|
||||
it('propagates generation cancellation without an orphan child or leaked budget', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-child-cancel-'));
|
||||
roots.push(root);
|
||||
const processBudget = new PiProcessBudget(8);
|
||||
let stopped = 0;
|
||||
const scheduler = new PiSubagentScheduler({
|
||||
processBudget,
|
||||
openChild: async (input) => ({
|
||||
id: input.taskId,
|
||||
async run(_prompt, signal) {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
const abort = () => reject(new PiSubagentChildError('SUBAGENT_ABORTED'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
return { summary: 'unreachable' };
|
||||
},
|
||||
async stop() { stopped += 1; },
|
||||
}),
|
||||
});
|
||||
let cancelGeneration: (() => void) | undefined;
|
||||
let untracked = 0;
|
||||
const host = new PiManagedExtensionHost();
|
||||
host.configureSubagents({
|
||||
scheduler,
|
||||
trackGenerationResource: (input) => {
|
||||
cancelGeneration = input.cancel;
|
||||
return () => { untracked += 1; };
|
||||
},
|
||||
});
|
||||
hosts.push(host);
|
||||
const parent = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||
});
|
||||
await host.bindRun('conversation-a', 1, 'run-a');
|
||||
const flight = post(parent, {
|
||||
action: 'subagent.dispatch', conversationId: 'conversation-a', workerGeneration: 1,
|
||||
runId: 'run-a', resourceId: 'subagent-tool',
|
||||
request: {
|
||||
mode: 'single',
|
||||
tasks: [{ agentId: 'agent-a', task: 'Wait', toolProfile: 'read-only' }],
|
||||
},
|
||||
});
|
||||
await expect.poll(() => processBudget.activeCount).toBe(1);
|
||||
cancelGeneration?.();
|
||||
const response = await flight;
|
||||
const lines = (await response.text()).trim().split('\n').map((line) => JSON.parse(line));
|
||||
expect(lines.at(-1)).toMatchObject({
|
||||
done: true,
|
||||
details: { tasks: [{ status: 'aborted', errorCode: 'SUBAGENT_ABORTED' }] },
|
||||
});
|
||||
expect(stopped).toBe(1);
|
||||
expect(processBudget.activeCount).toBe(0);
|
||||
expect(untracked).toBe(1);
|
||||
await scheduler.close();
|
||||
});
|
||||
|
||||
it('validates worker identity and enforces project-scoped leases over loopback HTTP', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-'));
|
||||
roots.push(root);
|
||||
@@ -152,4 +268,38 @@ describe('managed Pi extension bridge', () => {
|
||||
});
|
||||
expect(currentWorker.status).toBe(200);
|
||||
});
|
||||
|
||||
it('joins a coding child to the same project write lease as its parent', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-child-lease-'));
|
||||
roots.push(root);
|
||||
const leases = new PiProjectWriteLeaseCoordinator();
|
||||
const host = new PiManagedExtensionHost(leases);
|
||||
hosts.push(host);
|
||||
const parent = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||
});
|
||||
const child = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||
role: 'child', runId: 'run-a',
|
||||
});
|
||||
await host.bindRun('conversation-a', 1, 'run-a');
|
||||
const identity = { conversationId: 'conversation-a', workerGeneration: 1, runId: 'run-a' };
|
||||
const held = await post(parent, {
|
||||
...identity, action: 'lease.acquire', resourceId: 'parent-write',
|
||||
});
|
||||
const parentLease = await held.json() as { leaseId: string };
|
||||
let childSettled = false;
|
||||
const waiting = post(child, {
|
||||
...identity, action: 'lease.acquire', resourceId: 'child-write',
|
||||
}).then((response) => {
|
||||
childSettled = true;
|
||||
return response;
|
||||
});
|
||||
await expect.poll(() => leases.waitingCount('project-a')).toBe(1);
|
||||
expect(childSettled).toBe(false);
|
||||
expect((await post(parent, {
|
||||
...identity, action: 'lease.release', resourceId: 'parent-write', leaseId: parentLease.leaseId,
|
||||
})).status).toBe(200);
|
||||
expect((await waiting).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user