fix: class RpcProcessInstance as state machine

This commit is contained in:
Cristina Poncela Cubeiro
2026-06-26 12:47:09 +02:00
parent 9505389beb
commit 2f853bbce0
2 changed files with 289 additions and 190 deletions
+135 -136
View File
@@ -16,187 +16,186 @@ interface PendingRequest {
reject(error: Error): void; reject(error: Error): void;
} }
export interface RpcProcessInstance {
process: ChildProcess;
send(command: RpcCommand): Promise<RpcResponse>;
handleUiResponse(response: RpcExtensionUIResponse): void;
setUiRequestHandler(handler?: (request: RpcExtensionUIRequest) => void): void;
onEvent(listener: (event: AgentSessionEvent) => void): () => void;
onExit(listener: (error?: Error) => void): () => void;
dispose(): Promise<void>;
}
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
function getRpcSpawnCommand(): { command: string; args: string[] } {
if (isBunBinary) {
return {
command: join(dirname(process.execPath), process.platform === "win32" ? "pi.exe" : "pi"),
args: ["--mode", "rpc"],
};
}
return {
command: process.execPath,
args: [require.resolve("@earendil-works/pi-coding-agent/rpc-entry")],
};
}
function toError(error: unknown): Error { function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error)); return error instanceof Error ? error : new Error(String(error));
} }
export function createRpcProcessInstance(options: { cwd: string }): RpcProcessInstance { export class RpcProcessInstance {
const rpcCommand = getRpcSpawnCommand(); readonly process: ChildProcess;
const child = spawn(rpcCommand.command, rpcCommand.args, {
cwd: options.cwd, private exited = false;
env: process.env, private nextRequestId = 0;
stdio: ["pipe", "pipe", "pipe"], private stdoutBuffer = "";
}); private stderrBuffer = "";
if (!child.stdin || !child.stdout) { private readonly pendingRequests = new Map<string, PendingRequest>();
throw new Error("Failed to create RPC process stdio"); private readonly eventListeners = new Set<(event: AgentSessionEvent) => void>();
private readonly exitListeners = new Set<(error?: Error) => void>();
private uiRequestHandler: ((request: RpcExtensionUIRequest) => void) | undefined;
constructor(options: { cwd: string }) {
const rpcCommand = this.getSpawnCommand();
this.process = spawn(rpcCommand.command, rpcCommand.args, {
cwd: options.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
if (!this.process.stdin || !this.process.stdout) {
throw new Error("Failed to create RPC process stdio");
}
this.attachListeners();
} }
let exited = false; private getSpawnCommand(): { command: string; args: string[] } {
let nextRequestId = 0; if (isBunBinary) {
let stdoutBuffer = ""; return {
let stderrBuffer = ""; command: join(dirname(process.execPath), process.platform === "win32" ? "pi.exe" : "pi"),
const pendingRequests = new Map<string, PendingRequest>(); args: ["--mode", "rpc"],
const eventListeners = new Set<(event: AgentSessionEvent) => void>(); };
const exitListeners = new Set<(error?: Error) => void>();
const rejectAllPending = (error: Error) => {
for (const [id, pending] of pendingRequests) {
pendingRequests.delete(id);
pending.reject(error);
} }
}; return {
command: process.execPath,
args: [require.resolve("@earendil-works/pi-coding-agent/rpc-entry")],
};
}
const notifyExit = (error?: Error) => { private attachListeners(): void {
for (const listener of exitListeners) { this.process.stdout?.setEncoding("utf8");
listener(error); this.process.stdout?.on("data", (chunk: string) => {
} this.stdoutBuffer += chunk;
}; while (true) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
this.handleLine(line);
}
});
let uiRequestHandler: ((request: RpcExtensionUIRequest) => void) | undefined; this.process.stderr?.setEncoding("utf8");
this.process.stderr?.on("data", (chunk: string) => {
this.stderrBuffer += chunk;
});
const handleLine = (line: string) => { this.process.once("error", (error) => {
this.exited = true;
const wrapped = new Error(`RPC process error: ${error.message}. Stderr: ${this.stderrBuffer}`);
this.rejectAllPending(wrapped);
this.notifyExit(wrapped);
});
this.process.once("exit", (code, signal) => {
this.exited = true;
const error = new Error(`RPC process exited (code=${code} signal=${signal}). Stderr: ${this.stderrBuffer}`);
this.rejectAllPending(error);
this.notifyExit(error);
});
}
private handleLine(line: string): void {
const parsed = JSON.parse(line) as { type?: string; id?: string }; const parsed = JSON.parse(line) as { type?: string; id?: string };
switch (parsed.type) { switch (parsed.type) {
case "response": { case "response": {
if (!parsed.id) { if (!parsed.id) {
return; return;
} }
const pending = pendingRequests.get(parsed.id); const pending = this.pendingRequests.get(parsed.id);
if (!pending) { if (!pending) {
return; return;
} }
pendingRequests.delete(parsed.id); this.pendingRequests.delete(parsed.id);
pending.resolve(parsed as RpcResponse); pending.resolve(parsed as RpcResponse);
return; return;
} }
case "extension_ui_request": { case "extension_ui_request": {
uiRequestHandler?.(parsed as RpcExtensionUIRequest); this.uiRequestHandler?.(parsed as RpcExtensionUIRequest);
return; return;
} }
default: { default: {
for (const listener of eventListeners) { for (const listener of this.eventListeners) {
listener(parsed as AgentSessionEvent); listener(parsed as AgentSessionEvent);
} }
} }
} }
}; }
child.stdout.setEncoding("utf8"); private rejectAllPending(error: Error): void {
child.stdout.on("data", (chunk: string) => { for (const [id, pending] of this.pendingRequests) {
stdoutBuffer += chunk; this.pendingRequests.delete(id);
while (true) { pending.reject(error);
const newlineIndex = stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = stdoutBuffer.slice(0, newlineIndex).trim();
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
handleLine(line);
} }
}); }
child.stderr?.setEncoding("utf8"); private notifyExit(error?: Error): void {
child.stderr?.on("data", (chunk: string) => { for (const listener of this.exitListeners) {
stderrBuffer += chunk; listener(error);
});
child.once("error", (error) => {
exited = true;
const wrapped = new Error(`RPC process error: ${error.message}. Stderr: ${stderrBuffer}`);
rejectAllPending(wrapped);
notifyExit(wrapped);
});
child.once("exit", (code, signal) => {
exited = true;
const error = new Error(`RPC process exited (code=${code} signal=${signal}). Stderr: ${stderrBuffer}`);
rejectAllPending(error);
notifyExit(error);
});
const send = async (command: RpcCommand): Promise<RpcResponse> => {
if (exited) {
throw new Error(`RPC process is not running. Stderr: ${stderrBuffer}`);
} }
const id = command.id ?? `orchestrator_${++nextRequestId}_${randomUUID()}`; }
send(command: RpcCommand): Promise<RpcResponse> {
if (this.exited) {
throw new Error(`RPC process is not running. Stderr: ${this.stderrBuffer}`);
}
const id = command.id ?? `orchestrator_${++this.nextRequestId}_${randomUUID()}`;
const fullCommand = { ...command, id }; const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => { return new Promise<RpcResponse>((resolve, reject) => {
pendingRequests.set(id, { resolve, reject }); this.pendingRequests.set(id, { resolve, reject });
child.stdin.write(`${JSON.stringify(fullCommand)}\n`, (error) => { this.process.stdin?.write(`${JSON.stringify(fullCommand)}\n`, (error) => {
if (!error) { if (!error) {
return; return;
} }
pendingRequests.delete(id); this.pendingRequests.delete(id);
reject(toError(error)); reject(toError(error));
}); });
}); });
}; }
return { handleUiResponse(response: RpcExtensionUIResponse): void {
process: child, if (this.exited) {
send, return;
handleUiResponse(response) { }
if (exited) { this.process.stdin?.write(`${JSON.stringify(response)}\n`);
return; }
}
child.stdin.write(`${JSON.stringify(response)}\n`); setUiRequestHandler(handler?: (request: RpcExtensionUIRequest) => void): void {
}, this.uiRequestHandler = handler;
setUiRequestHandler(handler) { }
uiRequestHandler = handler;
}, onEvent(listener: (event: AgentSessionEvent) => void): () => void {
onEvent(listener) { this.eventListeners.add(listener);
eventListeners.add(listener); return () => {
return () => { this.eventListeners.delete(listener);
eventListeners.delete(listener); };
}; }
},
onExit(listener) { onExit(listener: (error?: Error) => void): () => void {
exitListeners.add(listener); this.exitListeners.add(listener);
return () => { return () => {
exitListeners.delete(listener); this.exitListeners.delete(listener);
}; };
}, }
async dispose() {
uiRequestHandler = undefined; async dispose(): Promise<void> {
rejectAllPending(new Error("RPC process disposed")); this.uiRequestHandler = undefined;
if (exited) { this.rejectAllPending(new Error("RPC process disposed"));
return; if (this.exited) {
} return;
child.kill("SIGTERM"); }
await new Promise<void>((resolve) => { this.process.kill("SIGTERM");
child.once("exit", () => resolve()); await new Promise<void>((resolve) => {
}); this.process.once("exit", () => resolve());
}, });
}; }
}
export function createRpcProcessInstance(options: { cwd: string }): RpcProcessInstance {
return new RpcProcessInstance(options);
} }
+154 -54
View File
@@ -10,11 +10,17 @@ import type {
import { radiusPresence } from "./radius.ts"; import { radiusPresence } from "./radius.ts";
import { createRpcProcessInstance, type RpcProcessInstance } from "./rpc-process.ts"; import { createRpcProcessInstance, type RpcProcessInstance } from "./rpc-process.ts";
import { getInstance, loadInstances, removeInstance, saveInstances, upsertInstance } from "./storage.ts"; import { getInstance, loadInstances, removeInstance, saveInstances, upsertInstance } from "./storage.ts";
import type { InstanceRecord } from "./types.ts"; import type { InstanceRecord, InstanceStatus } from "./types.ts";
interface LiveInstanceResources {
rpcProcess?: RpcProcessInstance;
radiusPiId?: string;
sessionId?: string;
}
interface LiveInstance { interface LiveInstance {
rpc: RpcProcessInstance;
record: InstanceRecord; record: InstanceRecord;
resources: LiveInstanceResources;
subscribers: Set<AgentSessionEventListener>; subscribers: Set<AgentSessionEventListener>;
onUiRequest?: (request: RpcExtensionUIRequest) => void; onUiRequest?: (request: RpcExtensionUIRequest) => void;
unsubscribeEvents?: () => void; unsubscribeEvents?: () => void;
@@ -57,51 +63,133 @@ function isGetStateSuccess(
export class OrchestratorSupervisor { export class OrchestratorSupervisor {
private readonly liveInstances = new Map<string, LiveInstance>(); private readonly liveInstances = new Map<string, LiveInstance>();
private async syncInstanceRecord(live: LiveInstance): Promise<void> { private setStatus(live: LiveInstance, status: InstanceStatus): void {
const response = await live.rpc.send({ type: "get_state" });
if (!isGetStateSuccess(response)) {
live.record = {
...live.record,
lastSeenAt: new Date().toISOString(),
};
upsertInstance(live.record);
return;
}
live.record = { live.record = {
...live.record, ...live.record,
sessionId: response.data.sessionId, status,
sessionFile: response.data.sessionFile,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
}; };
upsertInstance(live.record); upsertInstance(live.record);
} }
private bindLiveInstance(live: LiveInstance): void { private updateRecord(live: LiveInstance, updates: Partial<InstanceRecord>): void {
live.record = {
...live.record,
...updates,
lastSeenAt: new Date().toISOString(),
};
if (updates.radiusPiId !== undefined) {
live.resources.radiusPiId = updates.radiusPiId;
}
if (updates.sessionId !== undefined) {
live.resources.sessionId = updates.sessionId;
}
upsertInstance(live.record);
}
private clearBindings(live: LiveInstance): void {
live.unsubscribeEvents?.(); live.unsubscribeEvents?.();
live.unsubscribeExit?.(); live.unsubscribeExit?.();
live.unsubscribeEvents = live.rpc.onEvent((event) => { live.unsubscribeEvents = undefined;
live.unsubscribeExit = undefined;
live.onUiRequest = undefined;
live.resources.rpcProcess?.setUiRequestHandler(undefined);
}
private bindRpcProcess(live: LiveInstance, rpcProcess: RpcProcessInstance): void {
this.clearBindings(live);
live.resources.rpcProcess = rpcProcess;
live.unsubscribeEvents = rpcProcess.onEvent((event) => {
for (const subscriber of live.subscribers) { for (const subscriber of live.subscribers) {
subscriber(event); subscriber(event);
} }
}); });
live.unsubscribeExit = live.rpc.onExit(() => { live.unsubscribeExit = rpcProcess.onExit((error) => {
live.record = { void this.handleUnexpectedRpcExit(live, error);
...live.record,
status: "stopped",
lastSeenAt: new Date().toISOString(),
};
upsertInstance(live.record);
this.liveInstances.delete(live.record.id);
}); });
live.rpc.setUiRequestHandler((request) => { rpcProcess.setUiRequestHandler((request) => {
live.onUiRequest?.(request); live.onUiRequest?.(request);
}); });
} }
private async handleUnexpectedRpcExit(live: LiveInstance, _error?: Error): Promise<void> {
if (this.liveInstances.get(live.record.id) !== live) {
return;
}
if (live.record.status === "stopping" || live.record.status === "stopped") {
return;
}
this.setStatus(live, "error");
this.clearBindings(live);
live.resources.rpcProcess = undefined;
if (live.resources.radiusPiId) {
try {
await radiusPresence.disconnectPi(live.record);
this.updateRecord(live, { radiusPiId: undefined });
} catch (error) {
console.error(`Failed to disconnect Radius Pi ${live.record.id}: ${String(error)}`);
}
}
this.liveInstances.delete(live.record.id);
}
private getRpcProcess(live: LiveInstance): RpcProcessInstance | undefined {
return live.resources.rpcProcess;
}
private async syncInstanceRecord(live: LiveInstance): Promise<void> {
const rpcProcess = this.getRpcProcess(live);
if (!rpcProcess) {
this.updateRecord(live, {});
return;
}
const response = await rpcProcess.send({ type: "get_state" });
if (!isGetStateSuccess(response)) {
this.updateRecord(live, {});
return;
}
this.updateRecord(live, {
sessionId: response.data.sessionId,
sessionFile: response.data.sessionFile,
});
}
private async cleanupAcquiredResources(live: LiveInstance): Promise<void> {
const rpcProcess = live.resources.rpcProcess;
this.clearBindings(live);
if (live.resources.radiusPiId) {
await radiusPresence.disconnectPi(live.record);
live.resources.radiusPiId = undefined;
live.record = {
...live.record,
radiusPiId: undefined,
lastSeenAt: new Date().toISOString(),
};
}
live.resources.sessionId = undefined;
if (rpcProcess) {
live.resources.rpcProcess = undefined;
await rpcProcess.dispose();
}
}
private async failSpawn(live: LiveInstance, error: unknown): Promise<never> {
this.setStatus(live, "error");
try {
await this.cleanupAcquiredResources(live);
} finally {
this.setStatus(live, "stopped");
this.liveInstances.delete(live.record.id);
}
throw error;
}
updateInstance(instance: InstanceRecord): void { updateInstance(instance: InstanceRecord): void {
const live = this.liveInstances.get(instance.id); const live = this.liveInstances.get(instance.id);
if (live) { if (live) {
live.record = instance; live.record = instance;
live.resources.radiusPiId = instance.radiusPiId;
live.resources.sessionId = instance.sessionId;
} }
upsertInstance(instance); upsertInstance(instance);
} }
@@ -118,21 +206,22 @@ export class OrchestratorSupervisor {
} }
| undefined { | undefined {
const live = this.liveInstances.get(instanceId); const live = this.liveInstances.get(instanceId);
if (!live) { const rpcProcess = live ? this.getRpcProcess(live) : undefined;
if (!live || !rpcProcess) {
return undefined; return undefined;
} }
live.subscribers.add(onEvent); live.subscribers.add(onEvent);
live.onUiRequest = onUiRequest; live.onUiRequest = onUiRequest;
return { return {
handleRpc: async (command) => { handleRpc: async (command) => {
const response = await live.rpc.send(command); const response = await rpcProcess.send(command);
if (shouldRefreshSessionMetadata(command)) { if (shouldRefreshSessionMetadata(command)) {
await this.syncInstanceRecord(live); await this.syncInstanceRecord(live);
} }
return response; return response;
}, },
handleUiResponse: (response) => { handleUiResponse: (response) => {
live.rpc.handleUiResponse(response); rpcProcess.handleUiResponse(response);
}, },
close: () => { close: () => {
if (live.onUiRequest === onUiRequest) { if (live.onUiRequest === onUiRequest) {
@@ -179,28 +268,33 @@ export class OrchestratorSupervisor {
} }
async spawnInstance(options: { cwd: string; label?: string }): Promise<InstanceRecord> { async spawnInstance(options: { cwd: string; label?: string }): Promise<InstanceRecord> {
const rpc = createRpcProcessInstance({ cwd: options.cwd });
const now = new Date().toISOString(); const now = new Date().toISOString();
const record: InstanceRecord = {
id: randomUUID(),
status: "online",
cwd: options.cwd,
createdAt: now,
lastSeenAt: now,
label: options.label,
};
const registeredRecord = await radiusPresence.registerPi(record);
const live: LiveInstance = { const live: LiveInstance = {
rpc, record: {
record: registeredRecord, id: randomUUID(),
status: "starting",
cwd: options.cwd,
createdAt: now,
lastSeenAt: now,
label: options.label,
},
resources: {},
subscribers: new Set(), subscribers: new Set(),
}; };
this.bindLiveInstance(live); this.liveInstances.set(live.record.id, live);
this.liveInstances.set(registeredRecord.id, live);
await this.syncInstanceRecord(live);
upsertInstance(live.record); upsertInstance(live.record);
return cloneInstance(live.record);
try {
const rpcProcess = createRpcProcessInstance({ cwd: options.cwd });
this.bindRpcProcess(live, rpcProcess);
await this.syncInstanceRecord(live);
const registeredRecord = await radiusPresence.registerPi(live.record);
this.updateRecord(live, { radiusPiId: registeredRecord.radiusPiId });
this.setStatus(live, "online");
return cloneInstance(live.record);
} catch (error) {
return await this.failSpawn(live, error);
}
} }
async stopInstance(instanceId: string): Promise<InstanceRecord | undefined> { async stopInstance(instanceId: string): Promise<InstanceRecord | undefined> {
@@ -209,23 +303,29 @@ export class OrchestratorSupervisor {
return undefined; return undefined;
} }
await radiusPresence.disconnectPi(live.record); this.setStatus(live, "stopping");
live.unsubscribeEvents?.(); try {
live.unsubscribeExit?.(); await this.cleanupAcquiredResources(live);
live.onUiRequest = undefined; } finally {
await live.rpc.dispose(); live.record = {
this.liveInstances.delete(instanceId); ...live.record,
removeInstance(instanceId); status: "stopped",
lastSeenAt: new Date().toISOString(),
};
this.liveInstances.delete(instanceId);
removeInstance(instanceId);
}
return cloneInstance(live.record); return cloneInstance(live.record);
} }
async handleRpc(instanceId: string, command: RpcCommand): Promise<RpcResponse | undefined> { async handleRpc(instanceId: string, command: RpcCommand): Promise<RpcResponse | undefined> {
const live = this.liveInstances.get(instanceId); const live = this.liveInstances.get(instanceId);
if (!live) { const rpcProcess = live ? this.getRpcProcess(live) : undefined;
if (!live || !rpcProcess) {
return undefined; return undefined;
} }
const response = await live.rpc.send(command); const response = await rpcProcess.send(command);
if (shouldRefreshSessionMetadata(command)) { if (shouldRefreshSessionMetadata(command)) {
await this.syncInstanceRecord(live); await this.syncInstanceRecord(live);
} }