feat: add Pi process and RPC foundation
This commit is contained in:
38
electron/coding-runtime/pi/process-errors.ts
Normal file
38
electron/coding-runtime/pi/process-errors.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type PiProcessErrorCode =
|
||||
| 'PI_RPC_PROTOCOL_ERROR'
|
||||
| 'PI_RPC_TIMEOUT'
|
||||
| 'PI_RPC_ABORTED'
|
||||
| 'PI_RPC_RESPONSE_ERROR'
|
||||
| 'PI_RPC_WRITE_FAILED'
|
||||
| 'PI_RPC_EXITED'
|
||||
| 'PI_WORKER_START_FAILED'
|
||||
| 'PI_WORKER_STOPPED'
|
||||
| 'PI_WORKER_STOP_FAILED';
|
||||
|
||||
export type PiProcessErrorDetails = {
|
||||
cause?: unknown;
|
||||
generation?: number;
|
||||
diagnostic?: string;
|
||||
};
|
||||
|
||||
export class PiProcessError extends Error {
|
||||
readonly code: PiProcessErrorCode;
|
||||
readonly generation?: number;
|
||||
readonly diagnostic?: string;
|
||||
|
||||
constructor(
|
||||
code: PiProcessErrorCode,
|
||||
message: string,
|
||||
details: PiProcessErrorDetails = {},
|
||||
) {
|
||||
super(message, details.cause === undefined ? undefined : { cause: details.cause });
|
||||
this.name = 'PiProcessError';
|
||||
this.code = code;
|
||||
this.generation = details.generation;
|
||||
this.diagnostic = details.diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPiProcessError(error: unknown): error is PiProcessError {
|
||||
return error instanceof PiProcessError;
|
||||
}
|
||||
268
electron/coding-runtime/pi/rpc-client.ts
Normal file
268
electron/coding-runtime/pi/rpc-client.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { Writable } from 'node:stream';
|
||||
import { PiProcessError } from './process-errors';
|
||||
|
||||
export type PiRpcCommand = {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type PiRpcResponse<T = unknown> = {
|
||||
type: 'response';
|
||||
id: string;
|
||||
command?: string;
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PiRpcEvent = Record<string, unknown> & { type: string };
|
||||
export type PiRpcRetryPolicy = 'none' | 'read-only-once';
|
||||
|
||||
export type PiRpcRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
retry?: PiRpcRetryPolicy;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
commandType: string;
|
||||
resolve(response: PiRpcResponse): void;
|
||||
reject(error: PiProcessError): void;
|
||||
cancel(): void;
|
||||
};
|
||||
|
||||
type PiRpcClientOptions = {
|
||||
generation: number;
|
||||
defaultTimeoutMs?: number;
|
||||
idPrefix?: string;
|
||||
onEventListenerError?(error: unknown): void;
|
||||
};
|
||||
|
||||
const READ_ONLY_COMMANDS = new Set([
|
||||
'get_available_models',
|
||||
'get_available_thinking_levels',
|
||||
'get_commands',
|
||||
'get_entries',
|
||||
'get_fork_messages',
|
||||
'get_last_assistant_text',
|
||||
'get_messages',
|
||||
'get_session_stats',
|
||||
'get_state',
|
||||
'get_tree',
|
||||
]);
|
||||
|
||||
function recordValue(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function responseValue(value: Record<string, unknown>): value is PiRpcResponse {
|
||||
return value.type === 'response'
|
||||
&& typeof value.id === 'string'
|
||||
&& typeof value.success === 'boolean';
|
||||
}
|
||||
|
||||
export class PiRpcClient {
|
||||
private readonly writable: Writable;
|
||||
private readonly generation: number;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly idPrefix: string;
|
||||
private readonly onEventListenerError: ((error: unknown) => void) | undefined;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly retiredIds = new Set<string>();
|
||||
private readonly listeners = new Set<(event: PiRpcEvent) => void>();
|
||||
private sequence = 0;
|
||||
private invalidated: PiProcessError | null = null;
|
||||
|
||||
constructor(writable: Writable, options: PiRpcClientOptions) {
|
||||
if (!Number.isSafeInteger(options.generation) || options.generation <= 0) {
|
||||
throw new Error('generation must be a positive safe integer');
|
||||
}
|
||||
const defaultTimeoutMs = options.defaultTimeoutMs ?? 10_000;
|
||||
if (!Number.isSafeInteger(defaultTimeoutMs) || defaultTimeoutMs <= 0) {
|
||||
throw new Error('defaultTimeoutMs must be a positive safe integer');
|
||||
}
|
||||
this.writable = writable;
|
||||
this.generation = options.generation;
|
||||
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||
this.idPrefix = options.idPrefix ?? 'makelore';
|
||||
this.onEventListenerError = options.onEventListenerError;
|
||||
this.writable.on('error', (error) => {
|
||||
this.invalidate(new PiProcessError('PI_RPC_WRITE_FAILED', 'Pi RPC stdin failed', {
|
||||
cause: error,
|
||||
generation: this.generation,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
subscribe(listener: (event: PiRpcEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
command: PiRpcCommand,
|
||||
options: PiRpcRequestOptions = {},
|
||||
): Promise<PiRpcResponse<T>> {
|
||||
if (!command.type) throw new Error('Pi RPC command type is required');
|
||||
const retry = options.retry ?? 'none';
|
||||
if (retry === 'read-only-once' && !READ_ONLY_COMMANDS.has(command.type)) {
|
||||
throw new Error(`Pi RPC ${command.type} is not a retryable read-only command`);
|
||||
}
|
||||
|
||||
const attempts = retry === 'read-only-once' ? 2 : 1;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return await this.requestOnce<T>(command, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!(error instanceof PiProcessError) || error.code !== 'PI_RPC_TIMEOUT') throw error;
|
||||
if (this.invalidated || options.signal?.aborted) throw error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
accept(value: unknown): void {
|
||||
if (!recordValue(value) || typeof value.type !== 'string') {
|
||||
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC record must be an object with a type');
|
||||
}
|
||||
if (value.type === 'response') {
|
||||
if (!responseValue(value)) {
|
||||
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC response shape is invalid');
|
||||
}
|
||||
if (this.retiredIds.delete(value.id)) return;
|
||||
const pending = this.pending.get(value.id);
|
||||
if (!pending) {
|
||||
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', `Pi RPC response used unknown id ${value.id}`);
|
||||
}
|
||||
pending.cancel();
|
||||
this.pending.delete(value.id);
|
||||
if (value.success) pending.resolve(value);
|
||||
else {
|
||||
pending.reject(new PiProcessError(
|
||||
'PI_RPC_RESPONSE_ERROR',
|
||||
`Pi RPC ${value.command ?? pending.commandType} failed: ${value.error ?? 'unknown error'}`,
|
||||
{ generation: this.generation },
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(value as PiRpcEvent);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.onEventListenerError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must not affect transport state.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(error: PiProcessError): void {
|
||||
if (this.invalidated) return;
|
||||
this.invalidated = error;
|
||||
for (const [id, pending] of this.pending) {
|
||||
pending.cancel();
|
||||
this.retire(id);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private async requestOnce<T>(
|
||||
command: PiRpcCommand,
|
||||
options: PiRpcRequestOptions,
|
||||
): Promise<PiRpcResponse<T>> {
|
||||
if (this.invalidated) throw this.invalidated;
|
||||
if (options.signal?.aborted) {
|
||||
throw new PiProcessError('PI_RPC_ABORTED', `Pi RPC ${command.type} was aborted`, {
|
||||
generation: this.generation,
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error('timeoutMs must be a positive safe integer');
|
||||
}
|
||||
const id = `${this.idPrefix}-${this.generation}-${++this.sequence}`;
|
||||
|
||||
const response = new Promise<PiRpcResponse<T>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
this.retire(id);
|
||||
reject(new PiProcessError(
|
||||
'PI_RPC_TIMEOUT',
|
||||
`Pi RPC ${command.type} timed out after ${timeoutMs}ms`,
|
||||
{ generation: this.generation },
|
||||
));
|
||||
}, timeoutMs);
|
||||
const abort = (): void => {
|
||||
this.pending.delete(id);
|
||||
this.retire(id);
|
||||
clearTimeout(timeout);
|
||||
reject(new PiProcessError('PI_RPC_ABORTED', `Pi RPC ${command.type} was aborted`, {
|
||||
generation: this.generation,
|
||||
}));
|
||||
};
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
this.pending.set(id, {
|
||||
commandType: command.type,
|
||||
resolve: (value) => resolve(value as PiRpcResponse<T>),
|
||||
reject,
|
||||
cancel: () => {
|
||||
clearTimeout(timeout);
|
||||
options.signal?.removeEventListener('abort', abort);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const write = this.write(`${JSON.stringify({ ...command, id })}\n`).catch((error) => {
|
||||
throw new PiProcessError('PI_RPC_WRITE_FAILED', `Could not write Pi RPC ${command.type}`, {
|
||||
cause: error,
|
||||
generation: this.generation,
|
||||
});
|
||||
});
|
||||
try {
|
||||
const [, record] = await Promise.all([write, response]);
|
||||
return record;
|
||||
} catch (error) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) {
|
||||
pending.cancel();
|
||||
this.pending.delete(id);
|
||||
this.retire(id);
|
||||
pending.reject(error instanceof PiProcessError
|
||||
? error
|
||||
: new PiProcessError('PI_RPC_WRITE_FAILED', `Could not write Pi RPC ${command.type}`, {
|
||||
cause: error,
|
||||
generation: this.generation,
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private write(line: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.writable.write(line, (error?: Error | null) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private retire(id: string): void {
|
||||
this.retiredIds.add(id);
|
||||
if (this.retiredIds.size <= 256) return;
|
||||
const oldest = this.retiredIds.values().next().value as string | undefined;
|
||||
if (oldest) this.retiredIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
91
electron/coding-runtime/pi/rpc-framer.ts
Normal file
91
electron/coding-runtime/pi/rpc-framer.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { PiProcessError } from './process-errors';
|
||||
|
||||
export const DEFAULT_MAX_PI_RPC_LINE_BYTES = 1024 * 1024;
|
||||
|
||||
type StrictLfJsonlFramerOptions = {
|
||||
maxLineBytes?: number;
|
||||
onRecord(record: unknown): void;
|
||||
};
|
||||
|
||||
function protocolError(message: string): PiProcessError {
|
||||
return new PiProcessError('PI_RPC_PROTOCOL_ERROR', message);
|
||||
}
|
||||
|
||||
export class StrictLfJsonlFramer {
|
||||
private readonly decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
private readonly maxLineBytes: number;
|
||||
private readonly onRecord: (record: unknown) => void;
|
||||
private buffered = Buffer.alloc(0);
|
||||
private finished = false;
|
||||
|
||||
constructor(options: StrictLfJsonlFramerOptions) {
|
||||
const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_PI_RPC_LINE_BYTES;
|
||||
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) {
|
||||
throw new Error('maxLineBytes must be a positive safe integer');
|
||||
}
|
||||
this.maxLineBytes = maxLineBytes;
|
||||
this.onRecord = options.onRecord;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array | string): void {
|
||||
if (this.finished) throw protocolError('Pi RPC stdout continued after stream end');
|
||||
const bytes = typeof chunk === 'string'
|
||||
? Buffer.from(chunk, 'utf8')
|
||||
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const newline = bytes.indexOf(0x0a, offset);
|
||||
if (newline === -1) {
|
||||
this.append(bytes.subarray(offset));
|
||||
return;
|
||||
}
|
||||
this.append(bytes.subarray(offset, newline));
|
||||
this.emitLine();
|
||||
offset = newline + 1;
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.finished) return;
|
||||
this.finished = true;
|
||||
if (this.buffered.length > 0) {
|
||||
throw protocolError('Pi RPC stdout ended with a partial line');
|
||||
}
|
||||
}
|
||||
|
||||
private append(segment: Uint8Array): void {
|
||||
if (this.buffered.length + segment.byteLength > this.maxLineBytes) {
|
||||
throw protocolError(`Pi RPC stdout line exceeded ${this.maxLineBytes} bytes`);
|
||||
}
|
||||
if (segment.byteLength === 0) return;
|
||||
this.buffered = this.buffered.length === 0
|
||||
? Buffer.from(segment)
|
||||
: Buffer.concat([this.buffered, segment], this.buffered.length + segment.byteLength);
|
||||
}
|
||||
|
||||
private emitLine(): void {
|
||||
let line = this.buffered;
|
||||
this.buffered = Buffer.alloc(0);
|
||||
if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
|
||||
if (line.length === 0) throw protocolError('Pi RPC stdout emitted a blank line');
|
||||
|
||||
let source: string;
|
||||
try {
|
||||
source = this.decoder.decode(line);
|
||||
} catch (error) {
|
||||
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC stdout was not valid UTF-8', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this.onRecord(JSON.parse(source));
|
||||
} catch (error) {
|
||||
if (error instanceof PiProcessError) throw error;
|
||||
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC stdout contained malformed JSON', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
356
electron/coding-runtime/pi/worker-process.ts
Normal file
356
electron/coding-runtime/pi/worker-process.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
execFile,
|
||||
spawn,
|
||||
type ChildProcessWithoutNullStreams,
|
||||
} from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import { PiProcessError } from './process-errors';
|
||||
import {
|
||||
PiRpcClient,
|
||||
type PiRpcCommand,
|
||||
type PiRpcEvent,
|
||||
type PiRpcRequestOptions,
|
||||
type PiRpcResponse,
|
||||
} from './rpc-client';
|
||||
import { StrictLfJsonlFramer } from './rpc-framer';
|
||||
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SHUTDOWN_GRACE_MS = 3_000;
|
||||
const DEFAULT_DIAGNOSTIC_BYTES = 16_000;
|
||||
const ANSI_COLOR_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
|
||||
export type PiWorkerStopResult = {
|
||||
mode: 'not-started' | 'stdin-close' | 'forced-tree-kill';
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
export type PiWorkerProcessOptions = {
|
||||
executablePath: string;
|
||||
cliPath: string;
|
||||
cwd: string;
|
||||
configDir: string;
|
||||
sessionDir: string;
|
||||
additionalArgs?: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sensitiveValues?: readonly string[];
|
||||
commandTimeoutMs?: number;
|
||||
shutdownGraceMs?: number;
|
||||
maxLineBytes?: number;
|
||||
diagnosticBytes?: number;
|
||||
};
|
||||
|
||||
export function buildPiRpcArgs(
|
||||
sessionDir: string,
|
||||
additionalArgs: readonly string[] = [],
|
||||
): string[] {
|
||||
return [
|
||||
'--mode', 'rpc',
|
||||
'--offline',
|
||||
'--session-dir', sessionDir,
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-prompt-templates',
|
||||
'--no-themes',
|
||||
'--no-context-files',
|
||||
'--no-approve',
|
||||
'--no-tools',
|
||||
...additionalArgs,
|
||||
];
|
||||
}
|
||||
|
||||
export function sanitizePiDiagnostic(
|
||||
source: string,
|
||||
sensitiveValues: readonly string[] = [],
|
||||
): string {
|
||||
let sanitized = source
|
||||
.replace(ANSI_COLOR_PATTERN, '')
|
||||
.replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:x-api-key|api[_-]?key|token|secret)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]');
|
||||
for (const value of sensitiveValues) {
|
||||
if (value.length < 4) continue;
|
||||
sanitized = sanitized.split(value).join('[REDACTED]');
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function boundedUtf8Tail(source: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(source, 'utf8');
|
||||
if (bytes.length <= maxBytes) return source;
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
for (let start = bytes.length - maxBytes; start < bytes.length; start += 1) {
|
||||
try {
|
||||
return decoder.decode(bytes.subarray(start));
|
||||
} catch {
|
||||
// A UTF-8 code point occupies at most four bytes; advance to its boundary.
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function positiveInteger(value: number | undefined, fallback: number, name: string): number {
|
||||
const resolved = value ?? fallback;
|
||||
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
||||
throw new Error(`${name} must be a positive safe integer`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function runExecutable(executable: string, args: readonly string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(executable, [...args], { windowsHide: true }, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class PiWorkerProcess {
|
||||
private readonly options: PiWorkerProcessOptions;
|
||||
private readonly commandTimeoutMs: number;
|
||||
private readonly shutdownGraceMs: number;
|
||||
private readonly diagnosticBytes: number;
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
private rpc: PiRpcClient | null = null;
|
||||
private generationValue = 1;
|
||||
private diagnostic = '';
|
||||
private stdoutTail = '';
|
||||
private invalidation: PiProcessError | null = null;
|
||||
private stopping = false;
|
||||
private stopPromise: Promise<PiWorkerStopResult> | null = null;
|
||||
private exitResult: Promise<{ code: number | null; signal: NodeJS.Signals | null }> | null = null;
|
||||
|
||||
constructor(options: PiWorkerProcessOptions) {
|
||||
this.options = options;
|
||||
this.commandTimeoutMs = positiveInteger(
|
||||
options.commandTimeoutMs,
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
'commandTimeoutMs',
|
||||
);
|
||||
this.shutdownGraceMs = positiveInteger(
|
||||
options.shutdownGraceMs,
|
||||
DEFAULT_SHUTDOWN_GRACE_MS,
|
||||
'shutdownGraceMs',
|
||||
);
|
||||
this.diagnosticBytes = positiveInteger(
|
||||
options.diagnosticBytes,
|
||||
DEFAULT_DIAGNOSTIC_BYTES,
|
||||
'diagnosticBytes',
|
||||
);
|
||||
}
|
||||
|
||||
get generation(): number {
|
||||
return this.generationValue;
|
||||
}
|
||||
|
||||
get pendingCommandCount(): number {
|
||||
return this.rpc?.pendingCount ?? 0;
|
||||
}
|
||||
|
||||
get stderrDiagnostic(): string {
|
||||
return this.diagnostic;
|
||||
}
|
||||
|
||||
get protocolError(): PiProcessError | null {
|
||||
return this.invalidation?.code === 'PI_RPC_PROTOCOL_ERROR' ? this.invalidation : null;
|
||||
}
|
||||
|
||||
async start(): Promise<this> {
|
||||
if (this.child) throw new Error('Pi worker process already started');
|
||||
const generation = this.generationValue;
|
||||
const child = spawn(
|
||||
this.options.executablePath,
|
||||
[this.options.cliPath, ...buildPiRpcArgs(this.options.sessionDir, this.options.additionalArgs)],
|
||||
{
|
||||
cwd: this.options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.options.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
PI_CODING_AGENT_DIR: this.options.configDir,
|
||||
PI_OFFLINE: '1',
|
||||
PI_TELEMETRY: '0',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
detached: platform() !== 'win32',
|
||||
},
|
||||
);
|
||||
this.child = child;
|
||||
this.rpc = new PiRpcClient(child.stdin, {
|
||||
generation,
|
||||
defaultTimeoutMs: this.commandTimeoutMs,
|
||||
idPrefix: 'makelore-pi',
|
||||
onEventListenerError: (error) => {
|
||||
this.appendDiagnostic(`[event-listener] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
this.exitResult = new Promise((resolve) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
if (!this.stopping && !this.invalidation) {
|
||||
this.invalidate(new PiProcessError(
|
||||
'PI_RPC_EXITED',
|
||||
`Pi worker exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'})`,
|
||||
{ generation },
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
const framer = new StrictLfJsonlFramer({
|
||||
maxLineBytes: this.options.maxLineBytes,
|
||||
onRecord: (record) => this.acceptRecord(record),
|
||||
});
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
this.stdoutTail = boundedUtf8Tail(
|
||||
`${this.stdoutTail}${sanitizePiDiagnostic(chunk.toString('utf8'), this.options.sensitiveValues)}`,
|
||||
Math.min(this.diagnosticBytes, 2_048),
|
||||
);
|
||||
try {
|
||||
framer.push(chunk);
|
||||
} catch (error) {
|
||||
this.handleProtocolFailure(error);
|
||||
}
|
||||
});
|
||||
child.stdout.on('end', () => {
|
||||
try {
|
||||
framer.finish();
|
||||
} catch (error) {
|
||||
this.handleProtocolFailure(error);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => this.appendDiagnostic(chunk.toString('utf8')));
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = new PiProcessError('PI_WORKER_START_FAILED', 'Could not start Pi worker', {
|
||||
cause: error,
|
||||
generation,
|
||||
diagnostic: this.diagnostic,
|
||||
});
|
||||
this.invalidate(failure);
|
||||
throw failure;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
request<T = unknown>(
|
||||
command: PiRpcCommand,
|
||||
options?: PiRpcRequestOptions,
|
||||
): Promise<PiRpcResponse<T>> {
|
||||
if (!this.rpc) {
|
||||
return Promise.reject(new PiProcessError(
|
||||
'PI_WORKER_START_FAILED',
|
||||
'Pi worker has not started',
|
||||
{ generation: this.generationValue },
|
||||
));
|
||||
}
|
||||
return this.rpc.request<T>(command, options);
|
||||
}
|
||||
|
||||
subscribe(listener: (event: PiRpcEvent) => void): () => void {
|
||||
if (!this.rpc) throw new Error('Pi worker has not started');
|
||||
return this.rpc.subscribe(listener);
|
||||
}
|
||||
|
||||
stop(): Promise<PiWorkerStopResult> {
|
||||
if (!this.stopPromise) this.stopPromise = this.performStop();
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private acceptRecord(record: unknown): void {
|
||||
try {
|
||||
this.rpc?.accept(record);
|
||||
} catch (error) {
|
||||
this.handleProtocolFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleProtocolFailure(error: unknown): void {
|
||||
if (this.invalidation) return;
|
||||
const message = error instanceof Error ? error.message : 'Pi RPC protocol failure';
|
||||
this.appendDiagnostic(`[stdout-protocol] ${this.stdoutTail}\n`);
|
||||
const failure = new PiProcessError('PI_RPC_PROTOCOL_ERROR', message, {
|
||||
cause: error,
|
||||
generation: this.generationValue,
|
||||
diagnostic: this.diagnostic,
|
||||
});
|
||||
this.invalidate(failure);
|
||||
void this.forceKillTree();
|
||||
}
|
||||
|
||||
private invalidate(error: PiProcessError): void {
|
||||
if (this.invalidation) return;
|
||||
this.invalidation = error;
|
||||
this.rpc?.invalidate(error);
|
||||
this.generationValue += 1;
|
||||
}
|
||||
|
||||
private appendDiagnostic(source: string): void {
|
||||
const sanitized = sanitizePiDiagnostic(source, this.options.sensitiveValues);
|
||||
this.diagnostic = boundedUtf8Tail(`${this.diagnostic}${sanitized}`, this.diagnosticBytes);
|
||||
}
|
||||
|
||||
private async performStop(): Promise<PiWorkerStopResult> {
|
||||
const child = this.child;
|
||||
const exitResult = this.exitResult;
|
||||
if (!child || !exitResult) {
|
||||
return { mode: 'not-started', code: null, signal: null };
|
||||
}
|
||||
|
||||
this.stopping = true;
|
||||
this.invalidate(new PiProcessError('PI_WORKER_STOPPED', 'Pi worker stopped', {
|
||||
generation: this.generationValue,
|
||||
}));
|
||||
child.stdin.end();
|
||||
const graceful = await this.waitForExit(this.shutdownGraceMs);
|
||||
if (graceful) return { mode: 'stdin-close', ...graceful };
|
||||
|
||||
await this.forceKillTree();
|
||||
const forced = await this.waitForExit(this.shutdownGraceMs);
|
||||
if (forced) return { mode: 'forced-tree-kill', ...forced };
|
||||
throw new PiProcessError('PI_WORKER_STOP_FAILED', 'Pi worker did not exit after forced tree kill', {
|
||||
generation: this.generationValue,
|
||||
diagnostic: this.diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForExit(
|
||||
timeoutMs: number,
|
||||
): Promise<{ code: number | null; signal: NodeJS.Signals | null } | null> {
|
||||
if (!this.exitResult) return null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.exitResult,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async forceKillTree(): Promise<void> {
|
||||
const child = this.child;
|
||||
if (!child?.pid) return;
|
||||
if (platform() === 'win32') {
|
||||
await runExecutable('taskkill.exe', ['/pid', String(child.pid), '/t', '/f']).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user