Files
oh-my-pi/packages/coding-agent/src/launch/client.ts
T
can1357 4cfec93458 feat(coding-agent/launch): introduced persistent project service tool
- Introduced a project-scoped `launch` tool to orchestrate long-running services, debuggers, and watchers with persistent execution capabilities.
- Implemented a robust daemon broker with Unix/Windows IPC transport that manages process lifecycles, readiness monitoring, and automatic log rotation.
- Added detached process support to ensure services persist independently of the main application lifecycle, including recovery and cleanup mechanisms.
- Updated the `bash` interceptor to prioritize the new `launch` tool for background processes and provided comprehensive documentation for lifecycle and signal handling.
2026-07-13 04:21:55 +02:00

345 lines
11 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
import {
DAEMON_BROKER_WORKER_ARG,
DAEMON_IDLE_GRACE_ENV,
DAEMON_PROJECT_DIR_ENV,
DAEMON_RUNTIME_DIR_ENV,
type DaemonOperation,
type DaemonRpcResult,
type DaemonWireResponse,
parseDaemonRpcResult,
parseDaemonWireResponse,
} from "./protocol";
const CONNECT_TIMEOUT_MS = 10_000;
const CONNECT_RETRY_MS = 50;
const TOKEN_FILE = "broker.token";
interface PendingRequest {
operation: DaemonOperation;
resolve: (result: DaemonRpcResult) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
removeAbort?: () => void;
}
/** Broker location and lifecycle overrides used by smoke tests and isolated consumers. */
export interface DaemonBrokerClientOptions {
/** Runtime directory override; defaults to the project-scoped config path. */
runtimeDir?: string;
/** Last-client shutdown grace override in milliseconds. */
idleGraceMs?: number;
}
/** Persistent per-process connection to one project's daemon broker. */
export interface DaemonBrokerClient {
readonly projectDir: string;
request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
close(): void;
}
async function canonicalProjectDir(projectDir: string): Promise<string> {
const resolved = path.resolve(projectDir);
try {
return await fs.realpath(resolved);
} catch (error) {
if (isEnoent(error)) return resolved;
throw error;
}
}
async function readOrCreateToken(runtimeDir: string): Promise<string> {
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
const tokenPath = path.join(runtimeDir, TOKEN_FILE);
const tokenFile = Bun.file(tokenPath);
for (let attempt = 0; attempt < 100; attempt++) {
try {
const token = (await tokenFile.text()).trim();
if (token.length > 0) return token;
} catch (error) {
if (!isEnoent(error)) throw error;
}
try {
const handle = await fs.open(tokenPath, "wx", 0o600);
try {
const token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
await handle.writeFile(token, "utf8");
return token;
} finally {
await handle.close();
}
} catch (error) {
if (!isEexist(error)) throw error;
}
await Bun.sleep(10);
}
throw new Error(`Timed out initializing daemon broker token in ${runtimeDir}`);
}
function requestTimeoutMs(operation: DaemonOperation): number {
switch (operation.op) {
case "start":
return (operation.spec.ready?.timeoutMs ?? CONNECT_TIMEOUT_MS) + 5_000;
case "wait":
case "logs":
case "stop":
return operation.timeoutMs + 5_000;
default:
return 30_000;
}
}
function openSocket(endpoint: string, timeoutMs: number): Promise<net.Socket> {
const { promise, resolve, reject } = Promise.withResolvers<net.Socket>();
const socket = net.createConnection({ path: endpoint });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`Timed out connecting to daemon broker at ${endpoint}`));
}, timeoutMs);
const cleanup = (): void => {
clearTimeout(timer);
socket.off("connect", onConnect);
socket.off("error", onError);
};
const onConnect = (): void => {
cleanup();
resolve(socket);
};
const onError = (error: Error): void => {
cleanup();
socket.destroy();
reject(error);
};
socket.once("connect", onConnect);
socket.once("error", onError);
return promise;
}
class SocketDaemonClient implements DaemonBrokerClient {
readonly projectDir: string;
readonly #runtimeDir: string;
readonly #endpoint: string;
readonly #token: string;
readonly #idleGraceMs: number | undefined;
readonly #pending = new Map<string, PendingRequest>();
#socket: net.Socket | undefined;
#connectPromise: Promise<void> | undefined;
#buffer = "";
#closed = false;
constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
this.projectDir = projectDir;
this.#runtimeDir = runtimeDir;
this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
this.#token = token;
this.#idleGraceMs = options.idleGraceMs;
}
async request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult> {
if (this.#closed) throw new Error("Daemon broker client is closed");
if (signal?.aborted) throw new Error("Daemon broker request aborted");
await this.#connect();
const socket = this.#socket;
if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
const id = crypto.randomUUID();
const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
const timer = setTimeout(() => {
const pending = this.#pending.get(id);
if (!pending) return;
this.#pending.delete(id);
pending.removeAbort?.();
reject(new Error(`Daemon ${operation.op} request timed out`));
}, requestTimeoutMs(operation));
const pending: PendingRequest = { operation, resolve, reject, timer };
if (signal) {
const abort = (): void => {
if (!this.#pending.delete(id)) return;
clearTimeout(timer);
reject(new Error("Daemon broker request aborted"));
};
signal.addEventListener("abort", abort, { once: true });
pending.removeAbort = () => signal.removeEventListener("abort", abort);
}
this.#pending.set(id, pending);
socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
return promise;
}
close(): void {
if (this.#closed) return;
this.#closed = true;
this.#socket?.destroy();
this.#socket = undefined;
this.#rejectPending(new Error("Daemon broker client closed"));
}
async #connect(): Promise<void> {
if (this.#socket && !this.#socket.destroyed) return;
if (this.#connectPromise) return this.#connectPromise;
this.#connectPromise = this.#connectOnce();
try {
await this.#connectPromise;
} finally {
this.#connectPromise = undefined;
}
}
async #connectOnce(): Promise<void> {
try {
this.#bindSocket(await openSocket(this.#endpoint, 250));
return;
} catch {
// No live broker. Multiple clients may race to spawn; the broker's PID
// lease selects one winner before any candidate touches the socket.
}
this.#spawnBroker();
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
let lastError: Error | undefined;
while (Date.now() < deadline) {
try {
this.#bindSocket(await openSocket(this.#endpoint, 250));
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
await Bun.sleep(CONNECT_RETRY_MS);
}
}
throw new Error(`Failed to start daemon broker: ${lastError?.message ?? "socket unavailable"}`);
}
#spawnBroker(): void {
const spawn = resolveWorkerSpawnCmd(DAEMON_BROKER_WORKER_ARG);
const overlay: Record<string, string> = {
[DAEMON_PROJECT_DIR_ENV]: this.projectDir,
[DAEMON_RUNTIME_DIR_ENV]: this.#runtimeDir,
};
if (this.#idleGraceMs !== undefined) overlay[DAEMON_IDLE_GRACE_ENV] = String(this.#idleGraceMs);
const child = Bun.spawn(spawn.cmd, {
cwd: spawn.cwd,
env: workerEnvFromParent(overlay),
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
detached: true,
});
child.unref();
}
#bindSocket(socket: net.Socket): void {
this.#socket = socket;
this.#buffer = "";
socket.setEncoding("utf8");
socket.on("data", chunk => this.#onData(chunk));
socket.on("error", () => {
// The close handler rejects pending requests with one stable error.
});
socket.on("close", () => {
if (this.#socket === socket) this.#socket = undefined;
this.#rejectPending(new Error("Daemon broker connection closed"));
});
}
#onData(chunk: string | Buffer): void {
this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
for (;;) {
const newline = this.#buffer.indexOf("\n");
if (newline < 0) return;
const line = this.#buffer.slice(0, newline);
this.#buffer = this.#buffer.slice(newline + 1);
if (line.length === 0) continue;
let response: DaemonWireResponse;
try {
const decoded: unknown = JSON.parse(line);
response = parseDaemonWireResponse(decoded);
} catch (error) {
this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
continue;
}
const pending = this.#pending.get(response.id);
if (!pending) continue;
this.#pending.delete(response.id);
clearTimeout(pending.timer);
pending.removeAbort?.();
if (!response.ok) {
pending.reject(new Error(response.error));
continue;
}
try {
pending.resolve(parseDaemonRpcResult(pending.operation, response.result));
} catch (error) {
pending.reject(error instanceof Error ? error : new Error(String(error)));
}
}
}
#rejectPending(error: Error): void {
for (const pending of this.#pending.values()) {
clearTimeout(pending.timer);
pending.removeAbort?.();
pending.reject(error);
}
this.#pending.clear();
}
}
const sharedClients = new Map<string, Promise<DaemonBrokerClient>>();
let cancelExitCleanup: (() => void) | undefined;
/** Create an independent socket connection to one project's shared daemon broker. */
export async function createDaemonBrokerClient(
projectDir: string,
options: DaemonBrokerClientOptions = {},
): Promise<DaemonBrokerClient> {
const canonical = await canonicalProjectDir(projectDir);
const runtimeDir = options.runtimeDir ?? daemonRuntimeDir(canonical);
const token = await readOrCreateToken(runtimeDir);
return new SocketDaemonClient(canonical, runtimeDir, token, options);
}
/** Get the process-shared daemon broker client for one canonical project directory. */
export async function daemonClientForProject(projectDir: string): Promise<DaemonBrokerClient> {
const canonical = await canonicalProjectDir(projectDir);
let pending = sharedClients.get(canonical);
if (!pending) {
pending = createDaemonBrokerClient(canonical);
sharedClients.set(canonical, pending);
if (!cancelExitCleanup) {
cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
}
}
return pending;
}
/** Close every project broker connection held by this omp process. */
export async function closeDaemonClients(): Promise<void> {
const pending = [...sharedClients.values()];
sharedClients.clear();
for (const client of await Promise.all(pending)) client.close();
cancelExitCleanup?.();
cancelExitCleanup = undefined;
}
/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
export async function smokeTestDaemonBroker(): Promise<void> {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
try {
const ping = await client.request({ op: "ping" });
if (ping.op !== "ping" || ping.projectDir !== client.projectDir) throw new Error("daemon broker ping mismatch");
await client.request({ op: "shutdown" });
} finally {
client.close();
await fs.rm(projectDir, { recursive: true, force: true });
await fs.rm(runtimeDir, { recursive: true, force: true });
}
}