feat: add Pi process and RPC foundation

This commit is contained in:
2026-08-22 20:05:59 +08:00
parent b6f693048d
commit 9a31dacb2a
14 changed files with 1955 additions and 26 deletions

128
tests/fixtures/fake-pi-rpc-child.mjs vendored Normal file
View File

@@ -0,0 +1,128 @@
import { spawn } from 'node:child_process';
let input = '';
let held = null;
let keepAlive = null;
let descendant = null;
const counts = Object.create(null);
function response(command, data = {}) {
return JSON.stringify({
type: 'response',
id: command.id,
command: command.type,
success: true,
data,
});
}
function writeRecord(record) {
process.stdout.write(`${typeof record === 'string' ? record : JSON.stringify(record)}\n`);
}
function handle(command) {
counts[command.type] = (counts[command.type] ?? 0) + 1;
if (command.type === 'echo') {
writeRecord(response(command, { value: command.value }));
return;
}
if (command.type === 'emit_event') {
writeRecord({ type: 'agent_start', marker: command.marker });
writeRecord(response(command, { emitted: true }));
return;
}
if (command.type === 'hold') {
held = command;
return;
}
if (command.type === 'release') {
writeRecord(response(command, { order: 'first' }));
if (held) {
writeRecord(response(held, { order: 'second' }));
held = null;
}
return;
}
if (command.type === 'partial') {
const record = `${response(command, { partial: true })}\n`;
process.stdout.write(record.slice(0, 7));
setTimeout(() => process.stdout.write(record.slice(7, 19)), 5);
setTimeout(() => process.stdout.write(record.slice(19)), 10);
return;
}
if (command.type === 'malformed') {
process.stdout.write('not-json\n');
return;
}
if (command.type === 'blank') {
process.stdout.write('\n');
return;
}
if (command.type === 'invalid_utf8') {
process.stdout.write(Buffer.from([0xff, 0x0a]));
return;
}
if (command.type === 'trailing_partial') {
process.stdout.write(response(command, { partial: true }));
setTimeout(() => process.exit(0), 5);
return;
}
if (command.type === 'large') {
writeRecord(response(command, { value: 'x'.repeat(command.bytes ?? 4096) }));
return;
}
if (command.type === 'get_state' && command.fakeRetry) {
if (counts.get_state > 1) writeRecord(response(command, { attempts: counts.get_state }));
return;
}
if (command.type === 'no_response' || command.type === 'prompt') return;
if (command.type === 'stats') {
writeRecord(response(command, { counts }));
return;
}
if (command.type === 'stderr_secret') {
const secret = process.env.FAKE_PI_SECRET ?? 'missing-secret';
process.stderr.write(`${'diagnostic '.repeat(50)}\n`);
process.stderr.write(`Authorization: Bearer ${secret}\n`);
process.stderr.write(`token=${secret}\n`);
writeRecord(response(command, { wrote: true }));
return;
}
if (command.type === 'spawn_descendant') {
descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore',
windowsHide: true,
});
keepAlive = setInterval(() => {}, 1000);
writeRecord(response(command, { pid: descendant.pid }));
return;
}
if (command.type === 'crash') {
process.exit(7);
}
writeRecord({
type: 'response',
id: command.id,
command: command.type,
success: false,
error: 'unsupported fake command',
});
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
while (true) {
const newline = input.indexOf('\n');
if (newline === -1) break;
const line = input.slice(0, newline).replace(/\r$/, '');
input = input.slice(newline + 1);
if (line) handle(JSON.parse(line));
}
});
process.stdin.on('end', () => {
if (!keepAlive) process.exit(0);
});
process.on('exit', () => {
if (keepAlive) clearInterval(keepAlive);
});

View File

@@ -0,0 +1,271 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Writable } from 'node:stream';
import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import { PiRpcClient } from '../../electron/coding-runtime/pi/rpc-client';
import { StrictLfJsonlFramer } from '../../electron/coding-runtime/pi/rpc-framer';
import {
PiWorkerProcess,
buildPiRpcArgs,
sanitizePiDiagnostic,
} from '../../electron/coding-runtime/pi/worker-process';
const fakeChildPath = resolve('tests/fixtures/fake-pi-rpc-child.mjs');
const scratchRoots: string[] = [];
const workers: PiWorkerProcess[] = [];
async function makeWorker(
options: Partial<ConstructorParameters<typeof PiWorkerProcess>[0]> = {},
): Promise<PiWorkerProcess> {
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-rpc-test-'));
scratchRoots.push(root);
const configDir = join(root, 'config');
const sessionDir = join(root, 'sessions');
const cwd = join(root, 'project');
await Promise.all([
mkdir(configDir),
mkdir(sessionDir),
mkdir(cwd),
]);
const worker = new PiWorkerProcess({
executablePath: process.execPath,
cliPath: fakeChildPath,
cwd,
configDir,
sessionDir,
commandTimeoutMs: 1_000,
shutdownGraceMs: 500,
...options,
});
workers.push(worker);
return await worker.start();
}
async function processAlive(pid: number): Promise<boolean> {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
afterEach(async () => {
await Promise.all(workers.splice(0).map((worker) => worker.stop().catch(() => undefined)));
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
maxRetries: 3,
})));
});
describe('strict Pi LF JSONL framing', () => {
it('handles chunk boundaries, multiple records, CRLF, and Unicode separators', () => {
const records: unknown[] = [];
const framer = new StrictLfJsonlFramer({ onRecord: (record) => records.push(record) });
const source = Buffer.from(
`${JSON.stringify({ text: 'left\u2028middle\u2029right' })}\n${JSON.stringify({ ok: true })}\r\n`,
);
framer.push(source.subarray(0, 8));
framer.push(source.subarray(8, 23));
framer.push(source.subarray(23));
framer.finish();
expect(records).toEqual([
{ text: 'left\u2028middle\u2029right' },
{ ok: true },
]);
});
it('fails closed for malformed, blank, invalid UTF-8, oversized, and partial records', () => {
const make = (maxLineBytes = 64) => new StrictLfJsonlFramer({
maxLineBytes,
onRecord: () => undefined,
});
expect(() => make().push('not-json\n')).toThrow(/malformed JSON/);
expect(() => make().push('\n')).toThrow(/blank line/);
expect(() => make().push(Buffer.from([0xff, 0x0a]))).toThrow(/valid UTF-8/);
expect(() => make(4).push('12345')).toThrow(/exceeded 4 bytes/);
const partial = make();
partial.push('{"ok":true}');
expect(() => partial.finish()).toThrow(/partial line/);
});
});
describe('Pi RPC client', () => {
it('waits for writable completion when the stream applies backpressure', async () => {
let written = '';
let flush: (() => void) | undefined;
const writable = new Writable({
highWaterMark: 1,
write(chunk, _encoding, callback) {
written += chunk.toString();
flush = callback;
},
});
const client = new PiRpcClient(writable, { generation: 3, defaultTimeoutMs: 500 });
let settled = false;
const requested = client.request({ type: 'get_state' }).then((response) => {
settled = true;
return response;
});
await new Promise((resolvePromise) => setImmediate(resolvePromise));
const command = JSON.parse(written) as { id: string };
client.accept({ type: 'response', id: command.id, success: true, data: { ready: true } });
await new Promise((resolvePromise) => setImmediate(resolvePromise));
expect(settled).toBe(false);
flush?.();
await expect(requested).resolves.toMatchObject({ data: { ready: true } });
});
it('rejects retry policy for prompt-like mutation commands', async () => {
const writable = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
const client = new PiRpcClient(writable, { generation: 1 });
await expect(client.request(
{ type: 'prompt', message: 'do not replay' },
{ retry: 'read-only-once' },
)).rejects.toThrow(/not a retryable read-only command/);
await expect(client.request(
{ type: 'set_model', provider: 'provider-a', modelId: 'model-a' },
{ retry: 'read-only-once' },
)).rejects.toThrow(/not a retryable read-only command/);
});
it('settles and removes the pending command when the writable fails', async () => {
const writable = new Writable({
write(_chunk, _encoding, callback) {
callback(new Error('closed pipe'));
},
});
const client = new PiRpcClient(writable, { generation: 1 });
await expect(client.request({ type: 'get_state' }))
.rejects.toMatchObject({ code: 'PI_RPC_WRITE_FAILED' });
expect(client.pendingCount).toBe(0);
});
});
describe('Pi worker process', () => {
it('uses the locked offline and no-discovery RPC arguments', () => {
expect(buildPiRpcArgs('sessions', ['--model', 'model-a'])).toEqual([
'--mode', 'rpc',
'--offline',
'--session-dir', 'sessions',
'--no-extensions',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
'--no-context-files',
'--no-approve',
'--no-tools',
'--model', 'model-a',
]);
});
it('correlates out-of-order responses, dispatches events, and reassembles partial lines', async () => {
const worker = await makeWorker();
const events: unknown[] = [];
worker.subscribe(() => { throw new Error('consumer failed'); });
worker.subscribe((event) => events.push(event));
const held = worker.request<{ order: string }>({ type: 'hold' });
const released = worker.request<{ order: string }>({ type: 'release' });
await expect(released).resolves.toMatchObject({ data: { order: 'first' } });
await expect(held).resolves.toMatchObject({ data: { order: 'second' } });
await expect(worker.request({ type: 'emit_event', marker: 'event-a' }))
.resolves.toMatchObject({ data: { emitted: true } });
await expect(worker.request({ type: 'partial' }))
.resolves.toMatchObject({ data: { partial: true } });
expect(events).toContainEqual({ type: 'agent_start', marker: 'event-a' });
expect(worker.stderrDiagnostic).toContain('[event-listener] consumer failed');
await expect(worker.request({ type: 'echo', value: 'listener-isolated' }))
.resolves.toMatchObject({ data: { value: 'listener-isolated' } });
});
it('settles timeout and abort without replaying prompt, while read-only retry runs once', async () => {
const worker = await makeWorker({ commandTimeoutMs: 500 });
await expect(worker.request(
{ type: 'get_state', fakeRetry: true },
{ retry: 'read-only-once', timeoutMs: 500 },
)).resolves.toMatchObject({ data: { attempts: 2 } });
await expect(worker.request(
{ type: 'prompt', message: 'one attempt' },
{ timeoutMs: 100 },
)).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' });
const stats = await worker.request<{ counts: Record<string, number> }>({ type: 'stats' });
expect(stats.data?.counts.prompt).toBe(1);
const controller = new AbortController();
const pending = worker.request({ type: 'no_response' }, { signal: controller.signal });
const aborted = expect(pending).rejects.toMatchObject({ code: 'PI_RPC_ABORTED' });
controller.abort();
await aborted;
expect(worker.pendingCommandCount).toBe(0);
});
it.each(['malformed', 'blank', 'invalid_utf8', 'trailing_partial', 'large'])(
'invalidates only the target worker for %s stdout',
async (failureType) => {
const left = await makeWorker({ maxLineBytes: 128 });
const right = await makeWorker({ maxLineBytes: 128 });
const command = failureType === 'large'
? { type: failureType, bytes: 512 }
: { type: failureType };
await expect(left.request(command)).rejects.toMatchObject({ code: 'PI_RPC_PROTOCOL_ERROR' });
expect(left.generation).toBe(2);
expect(left.protocolError).toBeInstanceOf(PiProcessError);
expect(left.protocolError?.diagnostic).toContain('[stdout-protocol]');
await expect(right.request({ type: 'echo', value: 'still-alive' }))
.resolves.toMatchObject({ data: { value: 'still-alive' } });
},
);
it('settles every pending command after an unexpected exit', async () => {
const worker = await makeWorker();
const pending = worker.request({ type: 'no_response' });
const crash = worker.request({ type: 'crash' });
await expect(Promise.all([pending, crash])).rejects.toMatchObject({ code: 'PI_RPC_EXITED' });
expect(worker.pendingCommandCount).toBe(0);
expect(worker.generation).toBe(2);
});
it('keeps only bounded redacted stderr diagnostics', async () => {
const secret = 'credential-that-must-not-leak';
const worker = await makeWorker({
env: { FAKE_PI_SECRET: secret },
sensitiveValues: [secret],
diagnosticBytes: 160,
});
await worker.request({ type: 'stderr_secret' });
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
expect(worker.stderrDiagnostic).not.toContain(secret);
expect(worker.stderrDiagnostic).toContain('[REDACTED]');
expect(Buffer.byteLength(worker.stderrDiagnostic)).toBeLessThanOrEqual(160);
expect(sanitizePiDiagnostic(`token=${secret}`, [secret])).toBe('token=[REDACTED]');
});
it('forces the complete child tree down after the graceful deadline', async () => {
const worker = await makeWorker({ shutdownGraceMs: 100 });
const response = await worker.request<{ pid: number }>({ type: 'spawn_descendant' });
const descendantPid = response.data?.pid;
expect(descendantPid).toBeTypeOf('number');
await expect(worker.stop()).resolves.toMatchObject({ mode: 'forced-tree-kill' });
for (let attempt = 0; attempt < 20 && await processAlive(descendantPid!); attempt += 1) {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
expect(await processAlive(descendantPid!)).toBe(false);
});
});

View File

@@ -0,0 +1,137 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import YAML from 'yaml';
import {
assertPackageIdentities,
installProductionShrinkwrap,
preparePublishedPackageRoot,
requiredPackageIdentities,
} from '../../scripts/lib/pi-runtime-bundle.mjs';
import { parseBundleArgs } from '../../scripts/bundle-pi-runtime.mjs';
const scratchRoots: string[] = [];
async function scratch(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
scratchRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
maxRetries: 3,
})));
});
describe('Pi runtime production bundler', () => {
it('turns the published root dev mismatch into a reproducible npm 11 production install', async () => {
const root = await scratch('makelore-pi-bundle-mismatch-');
const source = join(root, 'published');
const stage = join(root, 'stage');
await mkdir(source);
await writeFile(join(source, 'package.json'), `${JSON.stringify({
name: '@fixture/pi-runtime',
version: '1.0.0',
devDependencies: { 'missing-from-production-lock': '1.0.0' },
}, null, 2)}\n`);
await writeFile(join(source, 'npm-shrinkwrap.json'), `${JSON.stringify({
name: '@fixture/pi-runtime',
version: '1.0.0',
lockfileVersion: 3,
requires: true,
packages: {
'': { name: '@fixture/pi-runtime', version: '1.0.0' },
},
}, null, 2)}\n`);
const prepared = await preparePublishedPackageRoot(source, stage);
expect(prepared.omittedDevDependencies).toEqual(['missing-from-production-lock']);
expect(JSON.parse(await readFile(join(stage, 'package.json'), 'utf8')))
.not.toHaveProperty('devDependencies');
await expect(installProductionShrinkwrap({
projectRoot: process.cwd(),
stageRoot: stage,
target: { platform: process.platform, arch: process.arch },
})).resolves.toMatchObject({});
}, 15_000);
it('accepts same-version nested-to-root flattening by identity instead of physical lock path', () => {
const shrinkwrap = {
packages: {
'node_modules/p-retry/node_modules/@types/retry': {
version: '0.12.0',
},
},
};
const required = requiredPackageIdentities(shrinkwrap, {
platform: process.platform,
arch: process.arch,
});
expect(required.identities).toEqual(['@types/retry@0.12.0']);
expect(() => assertPackageIdentities(required.identities, [
'@types/retry@0.12.0',
])).not.toThrow();
});
it('keeps target selection explicit and rejects cross-OS staging', () => {
const current = `${process.platform}-${process.arch}`;
expect(parseBundleArgs(['--target', current]).targets).toEqual([
expect.objectContaining({ platform: process.platform, arch: process.arch }),
]);
const foreign = process.platform === 'win32' ? 'linux-x64' : 'win32-x64';
expect(() => parseBundleArgs(['--target', foreign])).toThrow(/must be staged on/);
expect(() => parseBundleArgs(['--target', 'unknown-x64'])).toThrow(/Unsupported/);
});
it('wires every package target to its matching staged Pi runtime', async () => {
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
scripts: Record<string, string>;
};
const builder = YAML.parse(await readFile('electron-builder.yml', 'utf8')) as Record<
string,
{ extraResources?: Array<{ from: string; to: string }> }
>;
expect(packageJson.scripts.build).toContain('bundle-pi-runtime.mjs --release-targets');
expect(packageJson.scripts.package).toContain('bundle-pi-runtime.mjs --release-targets');
expect(packageJson.scripts['package:stage:win-x64'])
.toContain('bundle-pi-runtime.mjs --target win32-x64');
expect(builder.mac.extraResources).toContainEqual({
from: 'build/pi-runtime/darwin-${arch}',
to: 'pi-runtime',
filter: ['**/*', '!node_modules{,/**/*}'],
});
expect(builder.mac.extraResources).toContainEqual({
from: 'build/pi-runtime/darwin-${arch}/node_modules',
to: 'pi-runtime/node_modules',
filter: ['**/*'],
});
expect(builder.win.extraResources).toContainEqual({
from: 'build/pi-runtime/win32-${arch}',
to: 'pi-runtime',
filter: ['**/*', '!node_modules{,/**/*}'],
});
expect(builder.win.extraResources).toContainEqual({
from: 'build/pi-runtime/win32-${arch}/node_modules',
to: 'pi-runtime/node_modules',
filter: ['**/*'],
});
expect(builder.linux.extraResources).toContainEqual({
from: 'build/pi-runtime/linux-${arch}',
to: 'pi-runtime',
filter: ['**/*', '!node_modules{,/**/*}'],
});
expect(builder.linux.extraResources).toContainEqual({
from: 'build/pi-runtime/linux-${arch}/node_modules',
to: 'pi-runtime/node_modules',
filter: ['**/*'],
});
});
});

View File

@@ -0,0 +1,61 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { realpathSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { PiWorkerProcess } from '../../electron/coding-runtime/pi/worker-process';
const scratchRoots: string[] = [];
afterEach(async () => {
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
maxRetries: 3,
})));
});
describe('locked Pi worker process smoke', () => {
it('loads the real Pi 0.84.2 entry through Electron Node and exits by closing stdin', async () => {
const requireFromProject = createRequire(resolve('package.json'));
const electronExecutable = requireFromProject('electron') as string;
const packageRoot = realpathSync(resolve(
'node_modules',
'@earendil-works',
'pi-coding-agent',
));
const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as {
version: string;
};
expect(packageJson.version).toBe('0.84.2');
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-real-worker-'));
scratchRoots.push(root);
const configDir = join(root, 'config');
const sessionDir = join(root, 'sessions');
const cwd = join(root, 'project');
await Promise.all([mkdir(configDir), mkdir(sessionDir), mkdir(cwd)]);
const worker = await new PiWorkerProcess({
executablePath: electronExecutable,
cliPath: join(packageRoot, 'dist', 'cli.js'),
cwd,
configDir,
sessionDir,
commandTimeoutMs: 5_000,
}).start();
try {
await expect(worker.request({ type: 'get_state' })).resolves.toMatchObject({
type: 'response',
command: 'get_state',
success: true,
});
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
} finally {
await worker.stop().catch(() => undefined);
}
}, 15_000);
});