From ef93d9292e876544c23471252c25509314a9e464 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Fri, 14 Aug 2026 23:12:48 +0300 Subject: [PATCH 1/9] feat(coding-agent): configure experimental server session directory --- .../src/cli/experimental/commands/server.ts | 7 +++++- .../src/cli/experimental/runtime.ts | 16 ++++++++++++- .../experimental/session-worker-process.ts | 3 +++ .../src/cli/experimental/session-worker.ts | 7 ++++-- packages/coding-agent/src/main.ts | 2 +- .../test/experimental-cli-command.test.ts | 15 ++++++++++++ .../test/experimental-cli-resolution.test.ts | 15 ++++++++++++ .../test/experimental-remote-runtime.test.ts | 13 +++++++++- .../experimental-session-directory.test.ts | 24 +++++++++++++++++++ .../test/experimental-session-worker.test.ts | 17 ++++++++++--- 10 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/experimental-session-directory.test.ts diff --git a/packages/coding-agent/src/cli/experimental/commands/server.ts b/packages/coding-agent/src/cli/experimental/commands/server.ts index bf5147754..2948928b9 100644 --- a/packages/coding-agent/src/cli/experimental/commands/server.ts +++ b/packages/coding-agent/src/cli/experimental/commands/server.ts @@ -1,5 +1,5 @@ import type { AuthInput } from "../auth.ts"; -import { Command } from "../command.ts"; +import { Command, stringOption } from "../command.ts"; import { authTokenFileOption, authTokenOption, @@ -14,6 +14,7 @@ export interface ServerCommand { readonly command: "server"; readonly auth?: AuthInput; readonly listen?: readonly TransportAddress[]; + readonly sessionDir?: string; } export interface ServerCommandContext { @@ -21,14 +22,17 @@ export interface ServerCommandContext { } const listenOption = transportOption("--listen"); +const sessionDirOption = stringOption("--session-dir"); export const serverCommand = new Command("server") .option(listenOption) + .option(sessionDirOption) .option(authTokenOption) .option(authTokenFileOption) .build((input) => { const { auth, errors: authErrors } = parseAuth(input); const listen = input.values(listenOption); + const sessionDir = input.value(sessionDirOption); const { errors: optionErrors } = parseLegacyOptions(input); const errors = [...authErrors, ...optionErrors, ...unsupportedLegacyOptions("server", input)]; if (errors.length > 0) return { ok: false, errors }; @@ -38,6 +42,7 @@ export const serverCommand = new Command("s command: "server", ...(auth === undefined ? {} : { auth }), ...(listen.length === 0 ? {} : { listen }), + ...(sessionDir === undefined ? {} : { sessionDir }), }, }; }) diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 8622b050e..ed034e301 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -10,6 +10,8 @@ import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } import { isServerId } from "@earendil-works/pi-protocol"; import type { PiServer, PiServerHost } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; +import { getAgentDir } from "../../config.ts"; +import { resolvePath } from "../../utils/paths.ts"; import type { ClientCommand } from "./commands/client.ts"; import { DEMO_SESSION_IDS } from "./demo-sessions.ts"; import { acquireExperimentalServerProfile } from "./server-profile.ts"; @@ -21,6 +23,7 @@ const EXPERIMENTAL_SOCKET_ROOT = "/tmp"; export interface ExperimentalMemoryServer { readonly serverId: string; + readonly sessionDir: string; readonly socketPath: string; readonly server: PiServer; readonly workerPids: ReadonlyMap; @@ -40,6 +43,8 @@ export interface StartExperimentalMemoryServerOptions { readonly directory?: string; readonly path?: string; readonly serverId?: string; + /** Durable session directory. Defaults to the experimental directory under the configured agent directory. */ + readonly sessionDir?: string; } export interface StartExperimentalServerGenerationOptions { @@ -47,6 +52,8 @@ export interface StartExperimentalServerGenerationOptions { readonly directory?: string; /** Physical socket directory. Defaults to a short, private per-user runtime directory. */ readonly socketDirectory?: string; + /** Durable session directory. Defaults to the experimental directory under the configured agent directory. */ + readonly sessionDir?: string; } export interface RunExperimentalClientOptions { @@ -54,11 +61,16 @@ export interface RunExperimentalClientOptions { readonly directory?: string; } +export function resolveExperimentalSessionDirectory(sessionDir?: string): string { + return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions")); +} + /** Start the temporary in-memory list-and-attach server composition. */ export async function startExperimentalMemoryServer( options: StartExperimentalMemoryServerOptions = {}, ): Promise { const serverId = options.serverId ?? randomUUID(); + const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); const repo = new MemorySessionRepo(); for (const id of DEMO_SESSION_IDS) { const session = await repo.create({ id }); @@ -70,7 +82,7 @@ export async function startExperimentalMemoryServer( sessions: repo, createHarness: async (session) => { const sessionId = session.metadata.id; - const worker = await startExperimentalSessionWorker(sessionId); + const worker = await startExperimentalSessionWorker(sessionId, { sessionDir }); try { // Prototype-only adapter: the server opened this parent-repository // facade, while the child owns its independently restored Session. @@ -127,6 +139,7 @@ export async function startExperimentalMemoryServer( ); return { serverId, + sessionDir, socketPath, server, workerPids, @@ -158,6 +171,7 @@ export async function startExperimentalServerGeneration( directory, path: socketPath, serverId, + sessionDir: options.sessionDir, }); } catch (error) { try { diff --git a/packages/coding-agent/src/cli/experimental/session-worker-process.ts b/packages/coding-agent/src/cli/experimental/session-worker-process.ts index cb63b7241..8e7a25a7d 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker-process.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker-process.ts @@ -1,3 +1,4 @@ +import { isAbsolute } from "node:path"; import { type AgentHarness, MemorySessionRepo } from "@earendil-works/pi-agent-core"; import { isDemoSessionId } from "./demo-sessions.ts"; @@ -26,6 +27,8 @@ async function run(): Promise { // requested session through durable storage. const sessionId = process.argv[2]; if (!sessionId || !isDemoSessionId(sessionId)) throw new Error(`Unknown demo session: ${sessionId ?? ""}`); + const sessionDir = process.argv[3]; + if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory"); // Prototype-only isolated state. The parent and child intentionally seed // separate repositories; this does not provide persistence or shared state. diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index a7078f44c..8765e981c 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -1,4 +1,5 @@ import { type ChildProcess, fork } from "node:child_process"; +import { isAbsolute } from "node:path"; import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts"; const DEFAULT_STARTUP_TIMEOUT_MS = 5_000; @@ -13,6 +14,7 @@ export interface ExperimentalSessionWorker { } export interface StartExperimentalSessionWorkerOptions { + readonly sessionDir: string; readonly startupTimeoutMs?: number; readonly shutdownTimeoutMs?: number; readonly workerUrl?: URL; @@ -20,15 +22,16 @@ export interface StartExperimentalSessionWorkerOptions { export async function startExperimentalSessionWorker( sessionId: string, - options: StartExperimentalSessionWorkerOptions = {}, + options: StartExperimentalSessionWorkerOptions, ): Promise { + if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); const startupTimeoutMs = validateTimeout(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs"); const shutdownTimeoutMs = validateTimeout( options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs", ); const workerUrl = options.workerUrl ?? defaultWorkerUrl(); - const child = fork(workerUrl, [sessionId], { + const child = fork(workerUrl, [sessionId, options.sessionDir], { execArgv: workerExecArgv(workerUrl), stdio: ["ignore", "ignore", "inherit", "ipc"], }); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9fe763b3f..f5d8ed0c1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -590,7 +590,7 @@ async function waitForTermination(serverClosed: Promise): Promise { async function runExperimentalServerCommand(command: ServerCommand): Promise { if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server"); if (command.listen !== undefined) throw new Error("The local demo server uses its server-addressed Unix socket"); - const runtime = await startExperimentalServerGeneration(); + const runtime = await startExperimentalServerGeneration({ sessionDir: command.sessionDir }); console.log(`Server: ${runtime.serverId}`); console.log(`Socket: ${runtime.socketPath}`); try { diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index ed925813f..a6652d2cb 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -38,6 +38,16 @@ describe("experimental CLI commands", () => { }); }); + test("parses an experimental server session directory", () => { + expect(experimentalCli.parse(["server", "--session-dir", "~/pi-sessions"])).toEqual({ + ok: true, + command: { + command: "server", + sessionDir: "~/pi-sessions", + }, + }); + }); + test("leaves experimental-looking existing option values with the existing parser", () => { expect(experimentalCli.parse(["--system-prompt", "--listen", "unix:///tmp/pi.sock"])).toMatchObject({ ok: true, @@ -139,6 +149,11 @@ describe("experimental CLI commands", () => { "The experimental server command does not support existing CLI options yet", ], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], + [["server", "--session-dir"], "--session-dir requires a value"], + [ + ["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"], + "--session-dir may only be specified once", + ], [["--listen"], "--listen requires a value"], [["--connect="], "--connect is only valid for client mode"], ] as const)("rejects invalid experimental input %j", (argv, error) => { diff --git a/packages/coding-agent/test/experimental-cli-resolution.test.ts b/packages/coding-agent/test/experimental-cli-resolution.test.ts index 631c1e748..e7ecb203d 100644 --- a/packages/coding-agent/test/experimental-cli-resolution.test.ts +++ b/packages/coding-agent/test/experimental-cli-resolution.test.ts @@ -80,6 +80,21 @@ describe("experimental CLI command composition", () => { }); }); + test("passes the server session directory to the command action", async () => { + const runServer = vi.fn(() => undefined); + const result = await experimentalCli.execute(["server", "--session-dir", "./sessions"], { + runPi: vi.fn(() => undefined), + runServer, + runClient: vi.fn(() => undefined), + }); + + expect(result).toEqual({ + ok: true, + command: { command: "server", sessionDir: "./sessions" }, + }); + expect(runServer).toHaveBeenCalledWith({ command: "server", sessionDir: "./sessions" }); + }); + test.each(["pi", "server", "client"] as const)("executes the parsed %s command", async (name) => { const context = { runPi: vi.fn(() => undefined), diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index d243a8f43..063da3f7a 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,5 +1,5 @@ import { chmod, lstat, mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { type ExperimentalMemoryServer, @@ -41,6 +41,17 @@ describe("experimental memory server composition", () => { expect((await lstat(directory)).mode & 0o777).toBe(0o750); }); + test("resolves the configured session directory before starting workers", async () => { + const directory = await mkdtemp(join("/tmp", "pes-")); + directories.add(directory); + const runtime = await startExperimentalMemoryServer({ directory, sessionDir: "relative/sessions" }); + servers.add(runtime); + + expect(runtime.sessionDir).toBe(resolve("relative/sessions")); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + expect(runtime.workerPids.get("demo-1")).toEqual(expect.any(Number)); + }); + test("discovers and lists seeded sessions without hosting either session", async () => { const { directory, runtime } = await makeServer(); diff --git a/packages/coding-agent/test/experimental-session-directory.test.ts b/packages/coding-agent/test/experimental-session-directory.test.ts new file mode 100644 index 000000000..3e50eba19 --- /dev/null +++ b/packages/coding-agent/test/experimental-session-directory.test.ts @@ -0,0 +1,24 @@ +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { resolveExperimentalSessionDirectory } from "../src/cli/experimental/runtime.ts"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("experimental server session directory", () => { + test("uses the experimental directory under the configured agent directory by default", () => { + vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config"); + + expect(resolveExperimentalSessionDirectory()).toBe("/tmp/pi-agent-config/experimental/sessions"); + }); + + test("resolves an explicit relative directory from the current working directory", () => { + vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config"); + + expect(resolveExperimentalSessionDirectory("relative/sessions")).toBe(resolve("relative/sessions")); + }); + + test("expands a tilde in an explicit directory", () => { + expect(resolveExperimentalSessionDirectory("~/custom-sessions")).toBe(resolve(homedir(), "custom-sessions")); + }); +}); diff --git a/packages/coding-agent/test/experimental-session-worker.test.ts b/packages/coding-agent/test/experimental-session-worker.test.ts index b065665cc..cf6c1bb78 100644 --- a/packages/coding-agent/test/experimental-session-worker.test.ts +++ b/packages/coding-agent/test/experimental-session-worker.test.ts @@ -6,6 +6,7 @@ import { const workers = new Set(); const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url); +const sessionDir = "/tmp/pi-session-worker-tests"; afterEach(async () => { await Promise.all([...workers].map((worker) => worker.close())); @@ -14,7 +15,7 @@ afterEach(async () => { describe("experimental session worker controller", () => { test("starts a real child, validates readiness, and closes idempotently", async () => { - const worker = await startExperimentalSessionWorker("ready", { workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker("ready", { sessionDir, workerUrl: fixtureUrl }); workers.add(worker); expect(worker.sessionId).toBe("ready"); @@ -26,9 +27,16 @@ describe("experimental session worker controller", () => { await expect(worker.terminated).resolves.toBeUndefined(); }); + test("rejects a relative session directory before starting a child", async () => { + await expect( + startExperimentalSessionWorker("ready", { sessionDir: "relative", workerUrl: fixtureUrl }), + ).rejects.toThrow("sessionDir must be absolute"); + }); + test("times out and kills a child that never reports readiness", async () => { await expect( startExperimentalSessionWorker("startup-hang", { + sessionDir, workerUrl: fixtureUrl, startupTimeoutMs: 10, }), @@ -40,11 +48,14 @@ describe("experimental session worker controller", () => { ["exit before readiness", "exit", /exited before readiness/], ["invalid ready identity", "mismatch", /invalid identity/], ] as const)("rejects %s", async (_label, sessionId, message) => { - await expect(startExperimentalSessionWorker(sessionId, { workerUrl: fixtureUrl })).rejects.toThrow(message); + await expect(startExperimentalSessionWorker(sessionId, { sessionDir, workerUrl: fixtureUrl })).rejects.toThrow( + message, + ); }); test("kills a worker that ignores graceful shutdown", async () => { const worker = await startExperimentalSessionWorker("hang", { + sessionDir, workerUrl: fixtureUrl, shutdownTimeoutMs: 10, }); @@ -55,7 +66,7 @@ describe("experimental session worker controller", () => { }); test("reports an unexpected exit after readiness", async () => { - const worker = await startExperimentalSessionWorker("ready", { workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker("ready", { sessionDir, workerUrl: fixtureUrl }); workers.add(worker); process.kill(worker.pid, "SIGKILL"); From 95d74e8c6bb945a5fa7bf12f7f6ae109b43bc304 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Fri, 14 Aug 2026 23:57:14 +0300 Subject: [PATCH 2/9] feat(server): pass session metadata to harness hosts --- packages/client/test/unix.test.ts | 2 +- .../src/cli/experimental/runtime.ts | 12 +- packages/server/README.md | 57 ++++++--- packages/server/src/hosted-harness-manager.ts | 41 ++++--- packages/server/src/server.ts | 7 +- packages/server/src/testing/host.ts | 62 +++++----- packages/server/src/transports/unix/preset.ts | 6 +- packages/server/src/types.ts | 8 +- packages/server/test/conformance.test.ts | 109 +++++++++++++----- 9 files changed, 193 insertions(+), 111 deletions(-) diff --git a/packages/client/test/unix.test.ts b/packages/client/test/unix.test.ts index 231c8c83a..b2f2228f3 100644 --- a/packages/client/test/unix.test.ts +++ b/packages/client/test/unix.test.ts @@ -32,7 +32,7 @@ async function startServer( const path = join(directory, `${fileServerId}.sock`); const server = new PiServer( { - sessions: { list: async () => [], open: async () => Promise.reject(new Error("unused")) }, + sessions: { list: async () => [] }, createHarness: async () => Promise.reject(new Error("unused")), }, { listeners: [createUnixListener({ path })], serverId: reportedServerId }, diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index ed034e301..ad45a18df 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -80,17 +80,9 @@ export async function startExperimentalMemoryServer( const workerPids = new Map(); const host: PiServerHost = { sessions: repo, - createHarness: async (session) => { - const sessionId = session.metadata.id; + createHarness: async (metadata) => { + const sessionId = metadata.id; const worker = await startExperimentalSessionWorker(sessionId, { sessionDir }); - try { - // Prototype-only adapter: the server opened this parent-repository - // facade, while the child owns its independently restored Session. - await session.close(); - } catch (error) { - await worker.close(); - throw error; - } workerPids.set(sessionId, worker.pid); return { terminated: worker.terminated.then((error) => { diff --git a/packages/server/README.md b/packages/server/README.md index 5ef459127..becbaf2f8 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -5,7 +5,7 @@ Experimental local server for the new durable Session and Agent Harness interfac The current slice supports two client operations: - `list` calls `SessionRepo.list()` without opening sessions. -- `attach` finds the requested metadata, calls `SessionRepo.open()`, creates an `AgentHarness`, and retains that Harness in the server. +- `attach` finds the requested metadata, passes it to the host, and retains the returned Harness handle in the server. The separate launcher-control operation `drain` closes hosted Harnesses, acknowledges successful cleanup, and closes that server generation. It does not transfer hosted-session state, and the server does not decide whether a replacement should start. Clients explicitly reconnect and reattach after replacement. @@ -13,27 +13,50 @@ Concurrent attachments to one session reuse one hosted Harness. Attachment is a ```ts import { randomUUID } from "node:crypto"; -import { MemorySessionRepo } from "@earendil-works/pi-agent-core"; -import type { PiServerHost } from "@earendil-works/pi-server"; +import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core"; +import { + type HostedHarnessHandle, + type PiServerHost, +} from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; -const sessions = new MemorySessionRepo(); -const host: PiServerHost = { - sessions, - async createHarness(session) { - return createApplicationHarness({ session }); - }, -}; +async function startServer( + createHarnessForSession: (session: Session) => Promise, +) { + const sessions = new MemorySessionRepo(); + const host: PiServerHost = { + sessions, + async createHarness(metadata) { + const session = await sessions.open(metadata); + try { + return await createHarnessForSession(session); + } catch (error) { + try { + await session.close(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Harness creation and Session cleanup failed", + ); + } + throw error; + } + }, + }; -const serverId = randomUUID(); -const server = createUnixServer(host, { - serverId, - path: getUnixSocketPath(serverId, "/run/user/1000/pi"), -}); -await server.start(); + const serverId = randomUUID(); + const server = createUnixServer(host, { + serverId, + path: getUnixSocketPath(serverId, "/run/user/1000/pi"), + }); + await server.start(); + return server; +} ``` -Applications supply the repository and Harness factory. `serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process. +Applications supply a session catalog and a Harness factory. The server only calls `list()`; the host receives the repository's concrete metadata and owns opening the Session, creating the Harness, and cleaning up failed creation. This permits the host to perform those operations in a worker process without passing an open JavaScript Session across processes. + +`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process. `PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`. diff --git a/packages/server/src/hosted-harness-manager.ts b/packages/server/src/hosted-harness-manager.ts index 1645a044f..46c78f173 100644 --- a/packages/server/src/hosted-harness-manager.ts +++ b/packages/server/src/hosted-harness-manager.ts @@ -1,5 +1,7 @@ +import type { SessionMetadata } from "@earendil-works/pi-agent-core"; import { createRpcDispatcher, + type SessionMetadata as ProtocolSessionMetadata, ProtocolValidationError, ServiceRpc, type ServiceRpcCall, @@ -10,30 +12,43 @@ import type { HostedHarnessHandle, PiServerHost } from "./types.ts"; class HarnessCleanupError extends AggregateError {} +function toProtocolSessionMetadata(metadata: SessionMetadata): ProtocolSessionMetadata { + return { + id: metadata.id, + createdAt: metadata.createdAt, + storageVersion: metadata.storageVersion, + ...(metadata.cwd === undefined ? {} : { cwd: metadata.cwd }), + ...(metadata.parentSessionId === undefined ? {} : { parentSessionId: metadata.parentSessionId }), + ...(metadata.legacyParentSessionPath === undefined + ? {} + : { legacyParentSessionPath: metadata.legacyParentSessionPath }), + }; +} + interface HostedSession { readonly id: string; readonly harness: HostedHarnessHandle; } -interface HostedHarnessManagerOptions { - host: PiServerHost; +interface HostedHarnessManagerOptions { + host: PiServerHost; isClosing: () => boolean; reportError: (error: unknown) => void; } -export class HostedHarnessManager { - private readonly options: HostedHarnessManagerOptions; +export class HostedHarnessManager { + private readonly options: HostedHarnessManagerOptions; private readonly hostedSessions = new Map(); private readonly openingSessions = new Map>(); private closePromise?: Promise; private readonly dispatchRpc: (call: ServiceRpcCall, context: undefined) => Promise; - constructor(options: HostedHarnessManagerOptions) { + constructor(options: HostedHarnessManagerOptions) { this.options = options; this.dispatchRpc = createRpcDispatcher( ServiceRpc, { - list: () => this.options.host.sessions.list(), + list: async () => (await this.options.host.sessions.list()).map(toProtocolSessionMetadata), attach: async (_context, sessionId) => { if (this.options.isClosing()) throw new ServerDrainingError(); const hosted = await this.acquire(sessionId); @@ -93,19 +108,7 @@ export class HostedHarnessManager { private async open(sessionId: string): Promise { const metadata = (await this.options.host.sessions.list()).find((candidate) => candidate.id === sessionId); if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); - const session = await this.options.host.sessions.open(metadata); - let harness: HostedHarnessHandle; - try { - harness = await this.options.host.createHarness(session); - } catch (error) { - try { - await session.close(); - } catch (closeError) { - this.options.reportError(closeError); - throw new HarnessCleanupError([error, closeError], "Harness creation and Session cleanup failed"); - } - throw error; - } + const harness = await this.options.host.createHarness(metadata); if (this.options.isClosing()) { try { await harness.close(); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 328ea56a4..539f2ed5a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,3 +1,4 @@ +import type { SessionMetadata } from "@earendil-works/pi-agent-core"; import { type ClientHello, type ClientMessage, @@ -32,7 +33,7 @@ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000; const MAX_UINT32 = 0xffff_ffff; const MAX_TIMER_DELAY_MS = 2_147_483_647; -export class PiServer { +export class PiServer { readonly serverId: string; /** Resolves after shutdown, or rejects when listener or hosted-Harness cleanup fails. */ readonly closed: Promise; @@ -42,7 +43,7 @@ export class PiServer { private readonly handshakeTimeoutMs: number; private readonly onError: ((error: Error) => void) | undefined; private readonly connections = new Set(); - private readonly sessions: HostedHarnessManager; + private readonly sessions: HostedHarnessManager; private closing = false; private closePromise?: Promise; private closedSettled = false; @@ -51,7 +52,7 @@ export class PiServer { private startPromise?: Promise; private started = false; - constructor(host: PiServerHost, options: PiServerOptions) { + constructor(host: PiServerHost, options: PiServerOptions) { const resolved = resolveOptions(options); this.listeners = options.listeners; this.serverId = options.serverId; diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts index e58ee93f9..9bafdf7e9 100644 --- a/packages/server/src/testing/host.ts +++ b/packages/server/src/testing/host.ts @@ -73,9 +73,9 @@ interface ListDelay { export class TestServerHost implements PiServerHost { readonly repo = new MemorySessionRepo({ now: () => 1 }); readonly harnesses = new Map(); - openCount = 0; - failNextOpen?: Error; - failNextHarness?: Error; + createHarnessCount = 0; + nextCreateHarnessError?: Error; + nextHarnessCloseError?: Error; readonly sessions: PiServerHost["sessions"] = { list: async () => { const delay = this.nextListDelay; @@ -86,36 +86,38 @@ export class TestServerHost implements PiServerHost { } return this.repo.list(); }, - open: async (metadata) => { - this.openCount += 1; - const gate = this.nextOpenGate; - if (gate) { - this.nextOpenGate = undefined; - gate.entered.resolve(undefined); - await gate.release.promise; - } - if (this.failNextOpen) { - const error = this.failNextOpen; - this.failNextOpen = undefined; - throw error; - } - return this.repo.open(metadata); - }, }; private nextListDelay?: ListDelay; - private nextOpenGate?: OpenGate; + private nextCreateHarnessGate?: OpenGate; - async createHarness(session: Session): Promise { - if (this.failNextHarness) { - const error = this.failNextHarness; - this.failNextHarness = undefined; + async createHarness(metadata: SessionMetadata): Promise { + this.createHarnessCount += 1; + const gate = this.nextCreateHarnessGate; + if (gate) { + this.nextCreateHarnessGate = undefined; + gate.entered.resolve(undefined); + await gate.release.promise; + } + const session = await this.repo.open(metadata); + try { + if (this.nextCreateHarnessError) { + const error = this.nextCreateHarnessError; + this.nextCreateHarnessError = undefined; + throw error; + } + const harness = new TestHarness(session); + if (this.nextHarnessCloseError) { + harness.failClose = this.nextHarnessCloseError; + this.nextHarnessCloseError = undefined; + } + const harnesses = this.harnesses.get(metadata.id) ?? []; + harnesses.push(harness); + this.harnesses.set(metadata.id, harnesses); + return harness; + } catch (error) { + await session.close(); throw error; } - const harness = new TestHarness(session); - const harnesses = this.harnesses.get(session.metadata.id) ?? []; - harnesses.push(harness); - this.harnesses.set(session.metadata.id, harnesses); - return harness; } async seed(id = "session-1", parentSessionId?: string): Promise { @@ -131,9 +133,9 @@ export class TestServerHost implements PiServerHost { return delay; } - gateNextOpen(): OpenGate { + gateNextCreateHarness(): OpenGate { const gate = { entered: new Deferred(), release: new Deferred() }; - this.nextOpenGate = gate; + this.nextCreateHarnessGate = gate; return gate; } diff --git a/packages/server/src/transports/unix/preset.ts b/packages/server/src/transports/unix/preset.ts index 4904bc584..f2a5cfdcb 100644 --- a/packages/server/src/transports/unix/preset.ts +++ b/packages/server/src/transports/unix/preset.ts @@ -1,10 +1,14 @@ +import type { SessionMetadata } from "@earendil-works/pi-agent-core"; import { PiServer } from "../../server.ts"; import type { PiServerHost } from "../../types.ts"; import { createUnixListener } from "./listener.ts"; import type { UnixServerOptions } from "./types.ts"; /** Compose PiServer with one Unix-domain socket listener. */ -export function createUnixServer(host: PiServerHost, options: UnixServerOptions): PiServer { +export function createUnixServer( + host: PiServerHost, + options: UnixServerOptions, +): PiServer { const listener = createUnixListener({ path: options.path, mode: options.mode, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index b21f6dc13..c4852dc4f 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,4 +1,4 @@ -import type { AgentHarness, Session, SessionRepo } from "@earendil-works/pi-agent-core"; +import type { AgentHarness, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core"; import type { PiServerListener } from "./listener.ts"; export interface PiServerOptions { @@ -19,7 +19,7 @@ export interface HostedHarnessHandle extends Pick { } /** Host capabilities used directly by the list and attach control-plane operations. */ -export interface PiServerHost { - readonly sessions: Pick; - createHarness(session: Session): Promise; +export interface PiServerHost { + readonly sessions: Pick, "list">; + createHarness(metadata: TMetadata): Promise; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index 4dc517855..aa8ceb703 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -1,7 +1,9 @@ +import type { SessionMetadata } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, test } from "vitest"; import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; import { PiServer } from "../src/server.ts"; import { ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts"; +import type { PiServerHost } from "../src/types.ts"; const servers = new Set(); @@ -80,7 +82,54 @@ describe("list and attach protocol", () => { expect(host.harnesses.size).toBe(0); }); - test("attach opens the Session and creates one hosted Harness", async () => { + test("attach passes concrete repository metadata to the Harness host", async () => { + type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number }; + const metadata: BackendMetadata = { + id: "session-1", + createdAt: 1, + storageVersion: 1, + cwd: "/workspace", + path: "/sessions/session-1.jsonl", + modifiedAt: 2, + }; + let received: BackendMetadata | undefined; + const host: PiServerHost = { + sessions: { list: async () => [metadata] }, + createHarness: async (candidate) => { + received = candidate; + return { close: async () => {} }; + }, + }; + const server = new PiServer(host, { + listeners: [], + serverId: "00000000-0000-4000-8000-000000000001", + }); + servers.add(server); + const client = connect(server); + await client.hello(); + + await expect( + client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }), + ).resolves.toEqual({ + type: "response", + id: "request-1", + ok: true, + result: [ + { + id: "session-1", + createdAt: 1, + storageVersion: 1, + cwd: "/workspace", + }, + ], + }); + await expect( + client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }), + ).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } }); + expect(received).toBe(metadata); + }); + + test("attach creates one hosted Harness for concurrent callers", async () => { const host = new TestServerHost(); await host.seed("session-1"); const server = createServer(host); @@ -273,15 +322,15 @@ describe("server draining", () => { }); describe("hosted Harness acquisition failures", () => { - test("shares an opening failure and allows a later retry", async () => { + test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => { const host = new TestServerHost(); await host.seed("session-1"); - host.failNextOpen = new Error("open failed"); + host.nextCreateHarnessError = new Error("Harness creation failed"); const server = createServer(host); const first = connect(server); const second = connect(server); await Promise.all([first.hello(), second.hello()]); - const gate = host.gateNextOpen(); + const gate = host.gateNextCreateHarness(); const firstAttach = first.request("00000000-0000-4000-8000-000000000001", { method: "attach", @@ -298,31 +347,12 @@ describe("hosted Harness acquisition failures", () => { { ok: false, error: { code: "internal_error" } }, { ok: false, error: { code: "internal_error" } }, ]); - expect(host.openCount).toBe(1); + expect(host.createHarnessCount).toBe(1); await expect( first.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }), ).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } }); - expect(host.openCount).toBe(2); - expect(host.harnesses.get("session-1")).toHaveLength(1); - }); - - test("releases the opened Session when Harness creation fails", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - host.failNextHarness = new Error("harness failed"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - - await expect( - client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }), - ).resolves.toMatchObject({ ok: false, error: { code: "internal_error" } }); - - await expect( - client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }), - ).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } }); - expect(host.openCount).toBe(2); + expect(host.createHarnessCount).toBe(2); expect(host.harnesses.get("session-1")).toHaveLength(1); }); @@ -332,7 +362,7 @@ describe("hosted Harness acquisition failures", () => { const server = createServer(host); const client = connect(server); await client.hello(); - const gate = host.gateNextOpen(); + const gate = host.gateNextCreateHarness(); const attach = client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"], @@ -345,4 +375,31 @@ describe("hosted Harness acquisition failures", () => { await expect(attach).rejects.toThrow(/closed/i); expect(host.latestHarness("session-1").closeCount).toBe(1); }); + + test("fails shutdown when an in-flight acquisition cannot release its Harness", async () => { + const host = new TestServerHost(); + await host.seed("session-1"); + const cleanupError = new Error("close failed"); + host.nextHarnessCloseError = cleanupError; + const server = createServer(host); + const client = connect(server); + await client.hello(); + const gate = host.gateNextCreateHarness(); + const attach = client.request("00000000-0000-4000-8000-000000000001", { + method: "attach", + args: ["session-1"], + }); + await gate.entered.promise; + + const closing = server.close(); + gate.release.resolve(undefined); + + await expect(closing).rejects.toThrow(/Failed to close hosted Harnesses/); + await expect(server.closed).rejects.toThrow(/Failed to close hosted Harnesses/); + await expect(attach).rejects.toThrow(/closed/i); + expect(host.latestHarness("session-1").closeCount).toBe(1); + + servers.delete(server); + await host.latestHarness("session-1").close(); + }); }); From b35b4e0e489a96a8a83b1d533ee9bf6235749814 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 15 Aug 2026 01:01:12 +0300 Subject: [PATCH 3/9] feat(coding-agent): host durable sessions in workers --- .../src/cli/experimental/demo-sessions.ts | 9 -- .../src/cli/experimental/runtime.ts | 47 +++--- .../experimental/session-worker-process.ts | 145 ++++++++++++++---- .../src/cli/experimental/session-worker.ts | 6 +- .../test/experimental-remote-runtime.test.ts | 50 ++++-- .../test/experimental-session-support.ts | 32 ++++ .../test/experimental-session-worker.test.ts | 102 ++++++++++-- packages/protocol/src/schemas.ts | 1 + packages/server/src/errors.ts | 12 +- packages/server/src/hosted-harness-manager.ts | 8 +- packages/server/src/types.ts | 4 +- packages/server/test/conformance.test.ts | 26 ++++ vitest.base.ts | 2 + 13 files changed, 348 insertions(+), 96 deletions(-) delete mode 100644 packages/coding-agent/src/cli/experimental/demo-sessions.ts create mode 100644 packages/coding-agent/test/experimental-session-support.ts diff --git a/packages/coding-agent/src/cli/experimental/demo-sessions.ts b/packages/coding-agent/src/cli/experimental/demo-sessions.ts deleted file mode 100644 index 78bd37d46..000000000 --- a/packages/coding-agent/src/cli/experimental/demo-sessions.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Prototype-only shared catalog. Remove this once parent and worker resolve -// sessions from the same durable repository. -export const DEMO_SESSION_IDS = ["demo-1", "demo-2"] as const; - -export type DemoSessionId = (typeof DEMO_SESSION_IDS)[number]; - -export function isDemoSessionId(value: string): value is DemoSessionId { - return DEMO_SESSION_IDS.some((sessionId) => sessionId === value); -} diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index ad45a18df..297a50487 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -3,7 +3,8 @@ import { chmod, lstat, mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { MemorySessionRepo } from "@earendil-works/pi-agent-core"; +import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { PiClient } from "@earendil-works/pi-client"; import { requestServerDrain } from "@earendil-works/pi-client/control"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; @@ -13,7 +14,6 @@ import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/u import { getAgentDir } from "../../config.ts"; import { resolvePath } from "../../utils/paths.ts"; import type { ClientCommand } from "./commands/client.ts"; -import { DEMO_SESSION_IDS } from "./demo-sessions.ts"; import { acquireExperimentalServerProfile } from "./server-profile.ts"; import { startExperimentalSessionWorker } from "./session-worker.ts"; @@ -21,7 +21,7 @@ const SOCKET_RELEASE_TIMEOUT_MS = 10_000; const SOCKET_RELEASE_POLL_MS = 10; const EXPERIMENTAL_SOCKET_ROOT = "/tmp"; -export interface ExperimentalMemoryServer { +export interface ExperimentalServer { readonly serverId: string; readonly sessionDir: string; readonly socketPath: string; @@ -38,7 +38,7 @@ export type ExperimentalClientResult = } | { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string }; -export interface StartExperimentalMemoryServerOptions { +export interface StartExperimentalServerOptions { /** Directory for server-addressed Unix sockets. Defaults to a short, private per-user runtime directory. */ readonly directory?: string; readonly path?: string; @@ -65,24 +65,21 @@ export function resolveExperimentalSessionDirectory(sessionDir?: string): string return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions")); } -/** Start the temporary in-memory list-and-attach server composition. */ -export async function startExperimentalMemoryServer( - options: StartExperimentalMemoryServerOptions = {}, -): Promise { +/** Start the experimental durable list-and-attach server composition. */ +export async function startExperimentalServer( + options: StartExperimentalServerOptions = {}, +): Promise { const serverId = options.serverId ?? randomUUID(); const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); - const repo = new MemorySessionRepo(); - for (const id of DEMO_SESSION_IDS) { - const session = await repo.create({ id }); - await session.close(); - } + const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() }); + const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); const workerPids = new Map(); - const host: PiServerHost = { + const host: PiServerHost = { sessions: repo, createHarness: async (metadata) => { const sessionId = metadata.id; - const worker = await startExperimentalSessionWorker(sessionId, { sessionDir }); + const worker = await startExperimentalSessionWorker(metadata, { sessionDir }); workerPids.set(sessionId, worker.pid); return { terminated: worker.terminated.then((error) => { @@ -105,11 +102,17 @@ export async function startExperimentalMemoryServer( await ensurePrivateSocketDirectory(socketDirectory); socketPath = getUnixSocketPath(serverId, socketDirectory); } + const closeCatalog = async (): Promise => { + const cleanup = await Promise.allSettled([repo.close(), executionEnv.cleanup()]); + const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, "Experimental session catalog cleanup failed"); + }; const server = createUnixServer(host, { serverId, path: socketPath, mode: 0o600 }); try { await server.start(); } catch (error) { - const cleanup = await Promise.allSettled([server.close(), repo.close()]); + const cleanup = await Promise.allSettled([server.close(), closeCatalog()]); const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); if (cleanupErrors.length > 0) { throw new AggregateError([error, ...cleanupErrors], "Experimental server startup and cleanup failed"); @@ -119,10 +122,10 @@ export async function startExperimentalMemoryServer( let closePromise: Promise | undefined; const closed = server.closed.then( - () => repo.close(), + () => closeCatalog(), async (serverError: unknown) => { try { - await repo.close(); + await closeCatalog(); } catch (repoError) { throw new AggregateError([serverError, repoError], "Server and repository shutdown failed"); } @@ -150,16 +153,16 @@ export async function startExperimentalMemoryServer( /** Start the experimental local server, replacing an existing generation for the same profile. */ export async function startExperimentalServerGeneration( options: StartExperimentalServerGenerationOptions = {}, -): Promise { +): Promise { const directory = options.directory ?? join(homedir(), ".pi", "server"); const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory(); const { serverId, release } = await acquireExperimentalServerProfile(directory); - let runtime: ExperimentalMemoryServer; + let runtime: ExperimentalServer; try { await ensurePrivateSocketDirectory(socketDirectory); const socketPath = getUnixSocketPath(serverId, socketDirectory); await drainExistingGeneration(serverId, socketPath); - runtime = await startExperimentalMemoryServer({ + runtime = await startExperimentalServer({ directory, path: socketPath, serverId, @@ -207,7 +210,7 @@ export async function runExperimentalClient( command: ClientCommand, options: RunExperimentalClientOptions = {}, ): Promise { - if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server"); + if (command.auth !== undefined) throw new Error("Authentication is not supported by the experimental local server"); const routes = command.connect ? [routeFromExplicitPath(command.connect.path)] : await discoverUnixServers({ directory: options.directory ?? getExperimentalSocketDirectory() }); diff --git a/packages/coding-agent/src/cli/experimental/session-worker-process.ts b/packages/coding-agent/src/cli/experimental/session-worker-process.ts index 8e7a25a7d..e5fba0d14 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker-process.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker-process.ts @@ -1,8 +1,15 @@ import { isAbsolute } from "node:path"; -import { type AgentHarness, MemorySessionRepo } from "@earendil-works/pi-agent-core"; -import { isDemoSessionId } from "./demo-sessions.ts"; +import { + AgentHarness, + type AgentHarness as AgentHarnessInstance, + type JsonlSessionMetadata, + JsonlSessionRepo, + type Session, +} from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { ModelRuntime } from "../../core/model-runtime.ts"; -// Prototype-only control protocol. Real Harness operations will require a +// Prototype-only process control. Agent operations will use the shared // transport-independent protocol rather than additional ad hoc IPC messages. export type SessionWorkerCommand = { type: "shutdown" }; export type SessionWorkerEvent = @@ -22,38 +29,95 @@ function send(event: SessionWorkerEvent): Promise { }); } -async function run(): Promise { - // Prototype-only catalog validation. A production worker will resolve the - // requested session through durable storage. - const sessionId = process.argv[2]; - if (!sessionId || !isDemoSessionId(sessionId)) throw new Error(`Unknown demo session: ${sessionId ?? ""}`); - const sessionDir = process.argv[3]; - if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory"); +function parseMetadata(value: string | undefined): JsonlSessionMetadata { + if (value === undefined) throw new Error("Session worker requires session metadata"); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error("Session worker received invalid session metadata", { cause: error }); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Session worker received invalid session metadata"); + } + const candidate = parsed as Record; + if ( + typeof candidate.id !== "string" || + candidate.id.length === 0 || + !Number.isSafeInteger(candidate.createdAt) || + !Number.isSafeInteger(candidate.storageVersion) || + typeof candidate.cwd !== "string" || + !isAbsolute(candidate.cwd) || + typeof candidate.path !== "string" || + !isAbsolute(candidate.path) || + typeof candidate.modifiedAt !== "number" || + !Number.isFinite(candidate.modifiedAt) || + (candidate.parentSessionId !== undefined && typeof candidate.parentSessionId !== "string") || + (candidate.legacyParentSessionPath !== undefined && typeof candidate.legacyParentSessionPath !== "string") + ) { + throw new Error("Session worker received invalid session metadata"); + } + return candidate as unknown as JsonlSessionMetadata; +} - // Prototype-only isolated state. The parent and child intentionally seed - // separate repositories; this does not provide persistence or shared state. - const repo = new MemorySessionRepo(); - // Seed, close, list, and reopen to model restoring an existing Session - // instead of handing the freshly created facade directly to the Harness. - const created = await repo.create({ id: sessionId }); - await created.close(); - const metadata = (await repo.list()).find((candidate) => candidate.id === sessionId); - if (!metadata) throw new Error(`Unknown demo session: ${sessionId}`); - const session = await repo.open(metadata); - // Prototype-only close-capable Harness placeholder. A production worker will - // construct and own the real AgentHarness for this durable Session. - const harnessOwner: Pick = { close: () => session.close() }; +async function closeResources(resources: { + harness?: AgentHarnessInstance; + session?: Session; + repo: JsonlSessionRepo; + executionEnv: NodeExecutionEnv; +}): Promise { + const errors: unknown[] = []; + try { + if (resources.harness) await resources.harness.close(); + else await resources.session?.close(); + } catch (error) { + errors.push(error); + } + try { + await resources.repo.close(); + } catch (error) { + errors.push(error); + } + try { + await resources.executionEnv.cleanup(); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed"); +} + +async function run(): Promise { + const sessionDir = process.argv[2]; + if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory"); + const metadata = parseMetadata(process.argv[3]); + const sessionId = metadata.id; + + const executionEnv = new NodeExecutionEnv({ cwd: metadata.cwd }); + const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); + let session: Session | undefined; + let harness: AgentHarnessInstance | undefined; + try { + session = await repo.open(metadata); + const modelRuntime = await ModelRuntime.create(); + const model = modelRuntime.getAvailableSnapshot()[0]; + if (!model) throw new Error("Session worker could not find a configured model"); + ({ harness } = await AgentHarness.create({ session, models: modelRuntime, model, tools: [], resources: {} })); + } catch (error) { + try { + await closeResources({ harness, session, repo, executionEnv }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed"); + } + throw error; + } let closing: Promise | undefined; const close = (): Promise => { - if (!closing) { - closing = harnessOwner.close().finally(() => repo.close()); - } + closing ??= closeResources({ harness, repo, executionEnv }); return closing; }; - - process.on("message", (message: SessionWorkerCommand) => { - if (message?.type !== "shutdown") return; + const closeAndExit = (): void => { void close().then( () => process.exit(0), (error: unknown) => { @@ -61,12 +125,25 @@ async function run(): Promise { process.exit(1); }, ); - }); - process.once("disconnect", () => void close().finally(() => process.exit(0))); - process.once("SIGTERM", () => void close().finally(() => process.exit(0))); - process.once("SIGINT", () => void close().finally(() => process.exit(0))); + }; - await send({ type: "ready", sessionId, pid: process.pid }); + process.on("message", (message: SessionWorkerCommand) => { + if (message?.type === "shutdown") closeAndExit(); + }); + process.once("disconnect", closeAndExit); + process.once("SIGTERM", closeAndExit); + process.once("SIGINT", closeAndExit); + + try { + await send({ type: "ready", sessionId, pid: process.pid }); + } catch (error) { + try { + await close(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Session worker readiness and cleanup failed"); + } + throw error; + } } void run().catch(async (error: unknown) => { diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index 8765e981c..ceee791b9 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -1,5 +1,6 @@ import { type ChildProcess, fork } from "node:child_process"; import { isAbsolute } from "node:path"; +import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts"; const DEFAULT_STARTUP_TIMEOUT_MS = 5_000; @@ -21,17 +22,18 @@ export interface StartExperimentalSessionWorkerOptions { } export async function startExperimentalSessionWorker( - sessionId: string, + metadata: JsonlSessionMetadata, options: StartExperimentalSessionWorkerOptions, ): Promise { if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); + const sessionId = metadata.id; const startupTimeoutMs = validateTimeout(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs"); const shutdownTimeoutMs = validateTimeout( options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs", ); const workerUrl = options.workerUrl ?? defaultWorkerUrl(); - const child = fork(workerUrl, [sessionId, options.sessionDir], { + const child = fork(workerUrl, [options.sessionDir, JSON.stringify(metadata)], { execArgv: workerExecArgv(workerUrl), stdio: ["ignore", "ignore", "inherit", "ipc"], }); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 063da3f7a..edd6ac501 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,20 +1,30 @@ import { chmod, lstat, mkdtemp, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - type ExperimentalMemoryServer, + type ExperimentalServer, runExperimentalClient, - startExperimentalMemoryServer, + startExperimentalServer, startExperimentalServerGeneration, } from "../src/cli/experimental/runtime.ts"; +import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts"; -const servers = new Set(); +const servers = new Set(); const directories = new Set(); +let agentDir: string; -async function makeServer(): Promise<{ directory: string; runtime: ExperimentalMemoryServer }> { +beforeEach(async () => { + agentDir = await mkdtemp(join("/tmp", "pi-experimental-agent-")); + directories.add(agentDir); + await configureExperimentalWorkerModel(agentDir); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]); +}); + +async function makeServer(): Promise<{ directory: string; runtime: ExperimentalServer }> { const directory = await mkdtemp(join("/tmp", "pes-")); directories.add(directory); - const runtime = await startExperimentalMemoryServer({ directory }); + const runtime = await startExperimentalServer({ directory }); servers.add(runtime); return { directory, runtime }; } @@ -22,17 +32,18 @@ async function makeServer(): Promise<{ directory: string; runtime: ExperimentalM afterEach(async () => { await Promise.all([...servers].map((server) => server.close())); servers.clear(); + vi.unstubAllEnvs(); await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true }))); directories.clear(); }); -describe("experimental memory server composition", () => { +describe("experimental durable server composition", () => { test("does not change permissions on an explicit socket-path parent", async () => { const directory = await mkdtemp(join("/tmp", "pep-")); directories.add(directory); await chmod(directory, 0o750); const serverId = "00000000-0000-4000-8000-000000000001"; - const runtime = await startExperimentalMemoryServer({ + const runtime = await startExperimentalServer({ path: join(directory, `${serverId}.sock`), serverId, }); @@ -41,15 +52,14 @@ describe("experimental memory server composition", () => { expect((await lstat(directory)).mode & 0o777).toBe(0o750); }); - test("resolves the configured session directory before starting workers", async () => { + test("resolves the configured session directory", async () => { const directory = await mkdtemp(join("/tmp", "pes-")); directories.add(directory); - const runtime = await startExperimentalMemoryServer({ directory, sessionDir: "relative/sessions" }); + const runtime = await startExperimentalServer({ directory, sessionDir: "relative/sessions" }); servers.add(runtime); expect(runtime.sessionDir).toBe(resolve("relative/sessions")); - await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); - expect(runtime.workerPids.get("demo-1")).toEqual(expect.any(Number)); + expect(runtime.workerPids.size).toBe(0); }); test("discovers and lists seeded sessions without hosting either session", async () => { @@ -196,8 +206,8 @@ describe("experimental memory server composition", () => { test("reports missing and ambiguous session selections", async () => { const sharedDirectory = await mkdtemp(join("/tmp", "ped-")); directories.add(sharedDirectory); - const firstShared = await startExperimentalMemoryServer({ directory: sharedDirectory }); - const secondShared = await startExperimentalMemoryServer({ directory: sharedDirectory }); + const firstShared = await startExperimentalServer({ directory: sharedDirectory }); + const secondShared = await startExperimentalServer({ directory: sharedDirectory }); servers.add(firstShared); servers.add(secondShared); @@ -208,6 +218,18 @@ describe("experimental memory server composition", () => { runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: sharedDirectory }), ).rejects.toThrow("Session demo-1 is available from more than one server"); }); + test("rejects a duplicate session ID within one durable repository", async () => { + await createExperimentalSessions( + join(agentDir, "experimental", "sessions"), + ["demo-1"], + join(agentDir, "other-cwd"), + ); + const { directory } = await makeServer(); + + await expect( + runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }), + ).rejects.toMatchObject({ code: "session_ambiguous" }); + }); }); function processExists(pid: number): boolean { diff --git a/packages/coding-agent/test/experimental-session-support.ts b/packages/coding-agent/test/experimental-session-support.ts new file mode 100644 index 000000000..25d5b027d --- /dev/null +++ b/packages/coding-agent/test/experimental-session-support.ts @@ -0,0 +1,32 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; + +export async function createExperimentalSessions( + sessionsRoot: string, + ids: readonly string[], + cwd = process.cwd(), +): Promise { + const fileSystem = new NodeExecutionEnv({ cwd }); + const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot }); + const metadata: JsonlSessionMetadata[] = []; + try { + for (const id of ids) { + const session = await repo.create({ id, cwd }); + metadata.push(session.metadata); + await session.close(); + } + return metadata; + } finally { + await repo.close(); + await fileSystem.cleanup(); + } +} + +export async function configureExperimentalWorkerModel(agentDir: string): Promise { + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "auth.json"), JSON.stringify({ anthropic: { type: "api_key", key: "test-key" } }), { + mode: 0o600, + }); +} diff --git a/packages/coding-agent/test/experimental-session-worker.test.ts b/packages/coding-agent/test/experimental-session-worker.test.ts index cf6c1bb78..da1e3f4c4 100644 --- a/packages/coding-agent/test/experimental-session-worker.test.ts +++ b/packages/coding-agent/test/experimental-session-worker.test.ts @@ -1,21 +1,44 @@ -import { afterEach, describe, expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { type ExperimentalSessionWorker, startExperimentalSessionWorker, } from "../src/cli/experimental/session-worker.ts"; +import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts"; const workers = new Set(); const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url); const sessionDir = "/tmp/pi-session-worker-tests"; +const directories = new Set(); + +function testMetadata(id: string): JsonlSessionMetadata { + return { + id, + createdAt: 1, + storageVersion: 1, + cwd: "/tmp", + path: `/tmp/${id}.jsonl`, + modifiedAt: 1, + }; +} afterEach(async () => { await Promise.all([...workers].map((worker) => worker.close())); workers.clear(); + vi.unstubAllEnvs(); + await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true }))); + directories.clear(); }); describe("experimental session worker controller", () => { test("starts a real child, validates readiness, and closes idempotently", async () => { - const worker = await startExperimentalSessionWorker("ready", { sessionDir, workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker(testMetadata("ready"), { + sessionDir, + workerUrl: fixtureUrl, + }); workers.add(worker); expect(worker.sessionId).toBe("ready"); @@ -27,15 +50,26 @@ describe("experimental session worker controller", () => { await expect(worker.terminated).resolves.toBeUndefined(); }); + test("passes session IDs containing null bytes through serialized metadata", async () => { + const worker = await startExperimentalSessionWorker(testMetadata("ready\0"), { + sessionDir, + workerUrl: fixtureUrl, + }); + workers.add(worker); + + expect(worker.sessionId).toBe("ready\0"); + await worker.close(); + }); + test("rejects a relative session directory before starting a child", async () => { await expect( - startExperimentalSessionWorker("ready", { sessionDir: "relative", workerUrl: fixtureUrl }), + startExperimentalSessionWorker(testMetadata("ready"), { sessionDir: "relative", workerUrl: fixtureUrl }), ).rejects.toThrow("sessionDir must be absolute"); }); test("times out and kills a child that never reports readiness", async () => { await expect( - startExperimentalSessionWorker("startup-hang", { + startExperimentalSessionWorker(testMetadata("startup-hang"), { sessionDir, workerUrl: fixtureUrl, startupTimeoutMs: 10, @@ -48,13 +82,13 @@ describe("experimental session worker controller", () => { ["exit before readiness", "exit", /exited before readiness/], ["invalid ready identity", "mismatch", /invalid identity/], ] as const)("rejects %s", async (_label, sessionId, message) => { - await expect(startExperimentalSessionWorker(sessionId, { sessionDir, workerUrl: fixtureUrl })).rejects.toThrow( - message, - ); + await expect( + startExperimentalSessionWorker(testMetadata(sessionId), { sessionDir, workerUrl: fixtureUrl }), + ).rejects.toThrow(message); }); test("kills a worker that ignores graceful shutdown", async () => { - const worker = await startExperimentalSessionWorker("hang", { + const worker = await startExperimentalSessionWorker(testMetadata("hang"), { sessionDir, workerUrl: fixtureUrl, shutdownTimeoutMs: 10, @@ -66,7 +100,10 @@ describe("experimental session worker controller", () => { }); test("reports an unexpected exit after readiness", async () => { - const worker = await startExperimentalSessionWorker("ready", { sessionDir, workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker(testMetadata("ready"), { + sessionDir, + workerUrl: fixtureUrl, + }); workers.add(worker); process.kill(worker.pid, "SIGKILL"); @@ -75,6 +112,53 @@ describe("experimental session worker controller", () => { ); expect(processExists(worker.pid)).toBe(false); }); + + test("fails clearly when no model is configured", async () => { + const root = await mkdtemp(join("/tmp", "pi-session-worker-no-model-")); + directories.add(root); + const durableSessionDir = join(root, "sessions"); + const [metadata] = await createExperimentalSessions(durableSessionDir, ["no-model"]); + const agentDir = join(root, "agent"); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + + await expect(startExperimentalSessionWorker(metadata!, { sessionDir: durableSessionDir })).rejects.toThrow( + "could not find a configured model", + ); + }); + + test("opens a durable session and creates a real Harness before readiness", async () => { + const root = await mkdtemp(join("/tmp", "pi-session-worker-real-")); + directories.add(root); + const durableSessionDir = join(root, "sessions"); + const [metadata] = await createExperimentalSessions(durableSessionDir, ["durable-session"]); + const agentDir = join(root, "agent"); + await configureExperimentalWorkerModel(agentDir); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + + const worker = await startExperimentalSessionWorker(metadata!, { sessionDir: durableSessionDir }); + workers.add(worker); + + expect(worker.sessionId).toBe("durable-session"); + expect(worker.pid).not.toBe(process.pid); + expect(processExists(worker.pid)).toBe(true); + + await worker.close(); + await expect(worker.terminated).resolves.toBeUndefined(); + + const executionEnv = new NodeExecutionEnv({ cwd: metadata!.cwd }); + const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: durableSessionDir }); + const reopened = await repo.open(metadata!); + await expect(reopened.getRegister("lane.config", "main")).resolves.toMatchObject({ + value: { + model: { provider: "anthropic" }, + thinkingLevel: "off", + activeToolNames: [], + }, + }); + await reopened.close(); + await repo.close(); + await executionEnv.cleanup(); + }); }); function processExists(pid: number): boolean { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 81529dfd5..85e5d3eae 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -93,6 +93,7 @@ export const ProtocolErrorCodeSchema = Type.Union([ Type.Literal("version"), Type.Literal("wrong_server"), Type.Literal("session_not_found"), + Type.Literal("session_ambiguous"), Type.Literal("server_draining"), Type.Literal("invalid_request"), Type.Literal("internal_error"), diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 330a17e1c..ccd240163 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -1,6 +1,9 @@ import type { ProtocolErrorCode } from "@earendil-works/pi-protocol"; -type PiServerOperationErrorCode = Extract; +type PiServerOperationErrorCode = Extract< + ProtocolErrorCode, + "wrong_server" | "session_not_found" | "session_ambiguous" | "server_draining" +>; export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error"; @@ -29,6 +32,13 @@ export class SessionNotFoundError extends PiServerError { } } +export class SessionAmbiguousError extends PiServerError { + constructor() { + super("session_ambiguous", "Session ID matches more than one session"); + this.name = "SessionAmbiguousError"; + } +} + export class ServerDrainingError extends PiServerError { constructor() { super("server_draining", "Server is draining"); diff --git a/packages/server/src/hosted-harness-manager.ts b/packages/server/src/hosted-harness-manager.ts index 46c78f173..93815d13c 100644 --- a/packages/server/src/hosted-harness-manager.ts +++ b/packages/server/src/hosted-harness-manager.ts @@ -7,7 +7,7 @@ import { type ServiceRpcCall, type ServiceRpcResultUnion, } from "@earendil-works/pi-protocol"; -import { ServerDrainingError, SessionNotFoundError } from "./errors.ts"; +import { ServerDrainingError, SessionAmbiguousError, SessionNotFoundError } from "./errors.ts"; import type { HostedHarnessHandle, PiServerHost } from "./types.ts"; class HarnessCleanupError extends AggregateError {} @@ -106,8 +106,10 @@ export class HostedHarnessManager { - const metadata = (await this.options.host.sessions.list()).find((candidate) => candidate.id === sessionId); - if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); + const matches = (await this.options.host.sessions.list()).filter((candidate) => candidate.id === sessionId); + if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); + if (matches.length > 1) throw new SessionAmbiguousError(); + const metadata = matches[0]!; const harness = await this.options.host.createHarness(metadata); if (this.options.isClosing()) { try { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index c4852dc4f..f325a8e8d 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,4 +1,4 @@ -import type { AgentHarness, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core"; +import type { AgentHarness, SessionMetadata } from "@earendil-works/pi-agent-core"; import type { PiServerListener } from "./listener.ts"; export interface PiServerOptions { @@ -20,6 +20,6 @@ export interface HostedHarnessHandle extends Pick { /** Host capabilities used directly by the list and attach control-plane operations. */ export interface PiServerHost { - readonly sessions: Pick, "list">; + readonly sessions: { list(): Promise }; createHarness(metadata: TMetadata): Promise; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index aa8ceb703..d8ec9d389 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -185,6 +185,32 @@ describe("list and attach protocol", () => { expect(host.harnesses.size).toBe(0); }); + test("rejects an ambiguous session ID without creating a Harness", async () => { + type BackendMetadata = SessionMetadata & { path: string }; + const host: PiServerHost = { + sessions: { + list: async () => [ + { id: "duplicate", createdAt: 1, storageVersion: 1, cwd: "/one", path: "/one/session.jsonl" }, + { id: "duplicate", createdAt: 2, storageVersion: 1, cwd: "/two", path: "/two/session.jsonl" }, + ], + }, + createHarness: async () => { + throw new Error("must not create a Harness for an ambiguous session"); + }, + }; + const server = new PiServer(host, { + listeners: [], + serverId: "00000000-0000-4000-8000-000000000001", + }); + servers.add(server); + const client = connect(server); + await client.hello(); + + await expect( + client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["duplicate"] }), + ).resolves.toMatchObject({ ok: false, error: { code: "session_ambiguous" } }); + }); + test("invalidates a terminated Harness handle and allows a later attach", async () => { const host = new TestServerHost(); await host.seed("session-1"); diff --git a/vitest.base.ts b/vitest.base.ts index 3d6a8df7a..080b2b2a0 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -9,6 +9,7 @@ export const workspaceSourcePaths = { aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)), aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)), agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)), + agentNode: fileURLToPath(new URL("./packages/agent/src/node.ts", import.meta.url)), protocolIndex: fileURLToPath(new URL("./packages/protocol/src/index.ts", import.meta.url)), clientIndex: fileURLToPath(new URL("./packages/client/src/index.ts", import.meta.url)), clientControl: fileURLToPath(new URL("./packages/client/src/control.ts", import.meta.url)), @@ -32,6 +33,7 @@ export default defineConfig({ replacement: `${workspaceSourcePaths.aiProviders}/$1.ts`, }, { find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, + { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: workspaceSourcePaths.agentNode }, { find: /^@earendil-works\/pi-protocol$/, replacement: workspaceSourcePaths.protocolIndex }, { find: /^@earendil-works\/pi-client$/, replacement: workspaceSourcePaths.clientIndex }, { find: /^@earendil-works\/pi-client\/control$/, replacement: workspaceSourcePaths.clientControl }, From 33eef6d1c6363c970c5f9001b77e53c671c357e9 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 15 Aug 2026 01:04:45 +0300 Subject: [PATCH 4/9] test(coding-agent): read worker metadata in fixture --- .../test/fixtures/session-worker-fixture.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/fixtures/session-worker-fixture.ts b/packages/coding-agent/test/fixtures/session-worker-fixture.ts index 77eba715e..710b86cef 100644 --- a/packages/coding-agent/test/fixtures/session-worker-fixture.ts +++ b/packages/coding-agent/test/fixtures/session-worker-fixture.ts @@ -2,10 +2,15 @@ // lifecycle failure paths remain deterministic as production integration evolves. type Command = { type: "shutdown" }; -const mode = process.argv[2]; +const parsedMetadata: unknown = JSON.parse(process.argv[3] ?? "null"); +const sessionId = + typeof parsedMetadata === "object" && parsedMetadata !== null && "id" in parsedMetadata + ? parsedMetadata.id + : undefined; +const mode = typeof sessionId === "string" ? sessionId.replaceAll("\0", "") : undefined; if (mode === "ready") { - process.send?.({ type: "ready", sessionId: mode, pid: process.pid }); + process.send?.({ type: "ready", sessionId, pid: process.pid }); process.on("message", (message: Command) => { if (message?.type === "shutdown") process.exit(0); }); @@ -16,8 +21,12 @@ if (mode === "ready") { } else if (mode === "startup-hang") { process.on("message", () => {}); } else if (mode === "hang") { - process.send?.({ type: "ready", sessionId: mode, pid: process.pid }); + process.send?.({ type: "ready", sessionId, pid: process.pid }); process.on("message", () => {}); } else { - process.send?.({ type: "ready", sessionId: mode === undefined ? "missing" : `${mode}-different`, pid: process.pid }); + process.send?.({ + type: "ready", + sessionId: sessionId === undefined ? "missing" : `${sessionId}-different`, + pid: process.pid, + }); } From 8dce5da769cefd79b1ef043eabcd2277261f506d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 15 Aug 2026 11:49:53 +0200 Subject: [PATCH 5/9] feat(coding-agent): coordinate replaceable servers and workers Keep the stable socket in an opaque detached coordinator while server processes own detached worker discovery and lifecycle. Remove the public drain RPC in favor of server replacement through coordinator routing. --- packages/client/README.md | 13 +- packages/client/package.json | 4 - packages/client/src/control.ts | 125 ------- packages/client/test/control.test.ts | 63 ---- .../coordinated-session-workers.ts | 353 ++++++++++++++++++ .../cli/experimental/coordinator-client.ts | 282 ++++++++++++++ .../cli/experimental/coordinator-process.ts | 329 ++++++++++++++++ .../src/cli/experimental/runtime.ts | 197 ++++++---- .../experimental/session-worker-process.ts | 238 ++++++++++-- .../src/cli/experimental/session-worker.ts | 348 +++++++++++++---- packages/coding-agent/src/main.ts | 4 +- .../test/experimental-remote-runtime.test.ts | 157 ++++++-- .../experimental-server-replacement.test.ts | 14 +- .../test/fixtures/session-worker-fixture.ts | 40 +- packages/coding-agent/tsconfig.build.json | 1 - packages/protocol/README.md | 5 +- packages/protocol/src/schemas.ts | 29 +- packages/protocol/test/protocol.test.ts | 37 +- packages/server/README.md | 4 +- packages/server/src/server.ts | 26 +- packages/server/test/conformance.test.ts | 102 ----- tsconfig.json | 1 - vitest.base.ts | 2 - 23 files changed, 1725 insertions(+), 649 deletions(-) delete mode 100644 packages/client/src/control.ts delete mode 100644 packages/client/test/control.test.ts create mode 100644 packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts create mode 100644 packages/coding-agent/src/cli/experimental/coordinator-client.ts create mode 100644 packages/coding-agent/src/cli/experimental/coordinator-process.ts diff --git a/packages/client/README.md b/packages/client/README.md index 447ccccd3..7d04b979e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -27,18 +27,7 @@ The client verifies that the physical endpoint reports the expected logical `ser `attachSession()` currently returns only `{ sessionId }`. Remote Session and Harness methods will be added directly from the new shared interfaces in a later slice. The client does not reconnect or replay requests automatically. After disconnection, call `reconnect()` and explicitly repeat safe control-plane actions. -Server lifecycle control is not part of `PiClient`. Launchers use the separate `@earendil-works/pi-client/control` entry point to request a drain and wait for that server generation to close. - -```ts -import { requestServerDrain } from "@earendil-works/pi-client/control"; - -await requestServerDrain({ serverId, transportFactory }); -// Drain was acknowledged. Verifying endpoint release and starting a successor are launcher policy. -``` - -`requestServerDrain()` applies a 15-second total timeout across connection, handshake, and drain acknowledgement. Pass `timeoutMs` to override it. A timeout is ambiguous and is never replayed automatically. The transport-neutral helper does not prove process termination or endpoint release; a Unix launcher separately waits for its stable socket path to disappear. - -The control API is administrative, not an authorization boundary. Only expose it over a transport that authenticates launchers as administrators. The experimental local launcher relies on a mode-`0600` Unix socket and treats every same-user peer as trusted. +The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own session and worker lifecycle outside the public client protocol. Call transport handlers as follows: diff --git a/packages/client/package.json b/packages/client/package.json index f094d812a..45d5a103f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,10 +10,6 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./control": { - "types": "./dist/control.d.ts", - "import": "./dist/control.js" - }, "./unix": { "types": "./dist/unix.d.ts", "import": "./dist/unix.js" diff --git a/packages/client/src/control.ts b/packages/client/src/control.ts deleted file mode 100644 index 69431ef86..000000000 --- a/packages/client/src/control.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - createRpcClient, - encodeClientMessage, - isServerId, - ProtocolValidationError, - ServerControlRpc, - type ServerControlRpcCall, -} from "@earendil-works/pi-protocol"; -import { Connection } from "./connection.ts"; -import { PiDisconnectedError, PiServerError, toError } from "./errors.ts"; -import { createPromiseResolvers } from "./promise.ts"; -import type { ByteTransportFactory } from "./transport.ts"; - -const DEFAULT_SERVER_CONTROL_TIMEOUT_MS = 15_000; -const MAX_TIMER_DELAY_MS = 2_147_483_647; - -export interface ServerControlOptions { - readonly transportFactory: ByteTransportFactory; - /** Logical server identity expected at the physical endpoint. */ - readonly serverId: string; - readonly maxFrameLength?: number; - /** Total time allowed for handshake and drain acknowledgement. Defaults to 15 seconds. */ - readonly timeoutMs?: number; -} - -export class ServerControlTimeoutError extends Error { - readonly timeoutMs: number; - - constructor(timeoutMs: number) { - super(`Server control timed out after ${timeoutMs} ms`); - this.name = "ServerControlTimeoutError"; - this.timeoutMs = timeoutMs; - } -} - -interface PendingControlRequest { - readonly id: string; - resolve(result: unknown): void; - reject(error: Error): void; -} - -/** - * Ask one server generation to drain and wait for its acknowledgement. - * Physical endpoint shutdown is transport-specific launcher policy. - */ -export async function requestServerDrain(options: ServerControlOptions): Promise { - if (!isServerId(options.serverId)) { - throw new TypeError("Server control serverId must be a canonical lowercase UUIDv4"); - } - const timeoutMs = options.timeoutMs ?? DEFAULT_SERVER_CONTROL_TIMEOUT_MS; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { - throw new TypeError(`Server control timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`); - } - - let pending: PendingControlRequest | undefined; - const connection = new Connection({ - transportFactory: options.transportFactory, - serverId: options.serverId, - maxFrameLength: options.maxFrameLength, - onHandshake: () => {}, - onMessage: (message) => { - if (!pending || message.id !== pending.id) { - connection.fail(new ProtocolValidationError("Control response has no matching request")); - return; - } - const request = pending; - pending = undefined; - if (message.ok) request.resolve(message.result); - else request.reject(new PiServerError(message.error)); - }, - onStateChange: (change) => { - if (change.state !== "disconnected") return; - const request = pending; - pending = undefined; - request?.reject(change.error ?? new PiDisconnectedError()); - }, - }); - const control = createRpcClient( - ServerControlRpc, - (call) => - sendControlRequest(connection, options.serverId, call, (request) => { - pending = request; - }), - (message) => new ProtocolValidationError(message), - ); - - const timeout = setTimeout(() => { - connection.disconnect(new ServerControlTimeoutError(timeoutMs)); - }, timeoutMs); - timeout.unref(); - - try { - await connection.connect(); - await control.drain(); - } finally { - clearTimeout(timeout); - const request = pending; - pending = undefined; - request?.reject(new PiDisconnectedError("Server control client closed")); - connection.disconnect("Server control client closed"); - } -} - -function sendControlRequest( - connection: Connection, - serverId: string, - call: ServerControlRpcCall, - setPending: (request: PendingControlRequest) => void, -): Promise { - const id = "control-1"; - const { promise, resolve, reject } = createPromiseResolvers(); - setPending({ id, resolve, reject }); - let frame: Uint8Array; - try { - frame = encodeClientMessage( - { type: "request", id, serverId, call }, - { maxFrameLength: connection.maxFrameLength }, - ); - } catch (error) { - reject(toError(error)); - return promise; - } - connection.send(frame); - return promise; -} diff --git a/packages/client/test/control.test.ts b/packages/client/test/control.test.ts deleted file mode 100644 index 725961616..000000000 --- a/packages/client/test/control.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test, vi } from "vitest"; -import { requestServerDrain, ServerControlTimeoutError } from "../src/control.ts"; -import { MemoryByteServer } from "./support.ts"; - -test("launcher control resolves after the drain acknowledgement", async () => { - const server = new MemoryByteServer(); - const draining = requestServerDrain({ - serverId: "00000000-0000-4000-8000-000000000001", - transportFactory: (handlers) => server.connect(handlers), - }); - await server.waitForMessages(2); - expect(server.messages[1]).toEqual({ - type: "request", - id: "control-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "drain", args: [] }, - }); - - server.send({ - type: "response", - id: "control-1", - ok: true, - result: {}, - }); - await expect(draining).resolves.toBeUndefined(); - expect(server.clientCloseCount).toBe(1); -}); - -test("fails a control request that does not complete within its total timeout", async () => { - vi.useFakeTimers(); - try { - const server = new MemoryByteServer(); - const draining = requestServerDrain({ - serverId: "00000000-0000-4000-8000-000000000001", - transportFactory: (handlers) => server.connect(handlers), - timeoutMs: 25, - }); - await server.waitForMessages(2); - const observed = draining.then( - () => undefined, - (error: unknown) => error, - ); - - await vi.advanceTimersByTimeAsync(25); - const thrown = await observed; - expect(thrown).toBeInstanceOf(ServerControlTimeoutError); - expect(thrown).toMatchObject({ timeoutMs: 25 }); - expect(server.clientCloseCount).toBe(1); - } finally { - vi.useRealTimers(); - } -}); - -test("validates the control timeout", async () => { - const server = new MemoryByteServer(); - await expect( - requestServerDrain({ - serverId: "00000000-0000-4000-8000-000000000001", - transportFactory: (handlers) => server.connect(handlers), - timeoutMs: 0, - }), - ).rejects.toThrow(/timeoutMs/); -}); diff --git a/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts new file mode 100644 index 000000000..c5eafd4ae --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts @@ -0,0 +1,353 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; +import type { HostedHarnessHandle } from "@earendil-works/pi-server"; +import Type, { type Static } from "typebox"; +import { Check } from "typebox/value"; +import type { CoordinatorServer, CoordinatorServerEvent } from "./coordinator-client.ts"; +import { + createExperimentalSessionWorkerLaunchSpec, + SESSION_WORKER_CONTROL_ADDRESS_ENV, + SESSION_WORKER_CONTROL_TOKEN_ENV, + SESSION_WORKER_COORDINATED_ENV, + SESSION_WORKER_PEER_ID_ENV, + SESSION_WORKER_SESSION_KEY_ENV, +} from "./session-worker.ts"; + +const WORKER_STARTUP_TIMEOUT_MS = 15_000; +const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000; +const WORKER_DISCOVERY_TIMEOUT_MS = 5_000; + +const JsonlSessionMetadataSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + createdAt: Type.Integer(), + storageVersion: Type.Integer(), + cwd: Type.String(), + path: Type.String(), + modifiedAt: Type.Number(), + parentSessionId: Type.Optional(Type.String()), + legacyParentSessionPath: Type.Optional(Type.String()), +}); + +const WorkerMessageSchema = Type.Union([ + Type.Object({ + type: Type.Literal("worker_ready"), + token: Type.String(), + sessionKey: Type.String(), + sessionId: Type.String(), + pid: Type.Integer({ minimum: 1 }), + metadata: JsonlSessionMetadataSchema, + }), + Type.Object({ + type: Type.Literal("worker_failed"), + token: Type.String(), + sessionKey: Type.String(), + message: Type.String(), + }), +]); +type WorkerMessage = Static; + +interface WorkerRecord { + readonly peerId: string; + readonly metadata: JsonlSessionMetadata; + readonly pid: number; + readonly terminated: Promise; + resolveTerminated(error: Error | undefined): void; + expectedStop: boolean; +} + +interface PendingLaunch { + readonly sessionKey: string; + readonly peerId: string; + readonly token: string; + readonly child: ChildProcess; + readonly timer: NodeJS.Timeout; + readonly promise: Promise; + resolve(worker: WorkerRecord): void; + reject(error: Error): void; +} + +/** Session and process bookkeeping owned by one replaceable Pi server process. */ +export class CoordinatedSessionWorkers { + readonly workerPids = new Map(); + readonly #coordinator: CoordinatorServer; + readonly #sessionDir: string; + readonly #workersBySession = new Map(); + readonly #workersByPeer = new Map(); + readonly #pending = new Map(); + readonly #removeListener: () => void; + #discoveryPeers?: Set; + #resolveDiscovery?: () => void; + #detached = false; + #shuttingDown = false; + + constructor(coordinator: CoordinatorServer, sessionDir: string) { + this.#coordinator = coordinator; + this.#sessionDir = sessionDir; + this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event)); + } + + get trackedSessions(): readonly JsonlSessionMetadata[] { + return [...this.#workersBySession.values()].map((worker) => worker.metadata); + } + + async discover(peerIds: ReadonlySet): Promise { + if (this.#detached) return; + const undiscovered = new Set( + [...peerIds].filter((peerId) => !this.#workersByPeer.has(peerId) && !this.#pendingPeer(peerId)), + ); + if (undiscovered.size === 0) return; + this.#discoveryPeers = undiscovered; + const discovered = new Promise((resolve) => { + this.#resolveDiscovery = resolve; + }); + await this.#coordinator.broadcast({ type: "discover_workers" }); + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + discovered, + new Promise((resolve) => { + timer = setTimeout(resolve, WORKER_DISCOVERY_TIMEOUT_MS); + timer.unref(); + }), + ]); + if (timer) clearTimeout(timer); + this.#discoveryPeers = undefined; + this.#resolveDiscovery = undefined; + } + + async createHarness(metadata: JsonlSessionMetadata): Promise { + if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down"); + const existing = this.#workersBySession.get(metadata.path); + if (existing) return { terminated: existing.terminated, close: async () => {} }; + const pending = this.#pending.get(metadata.path); + if (pending) { + const worker = await pending.promise; + return { terminated: worker.terminated, close: async () => {} }; + } + const worker = await this.#launch(metadata); + return { terminated: worker.terminated, close: async () => {} }; + } + + async shutdown(): Promise { + if (this.#detached || this.#shuttingDown) return; + this.#shuttingDown = true; + for (const pending of this.#pending.values()) { + void this.#coordinator.send(pending.peerId, { type: "shutdown" }).catch(() => {}); + } + const workers = [...this.#workersBySession.values()]; + for (const worker of workers) { + worker.expectedStop = true; + void this.#coordinator.send(worker.peerId, { type: "shutdown" }).catch(() => {}); + } + const finished = Promise.all([ + ...workers.map((worker) => worker.terminated), + ...[...this.#pending.values()].map((pending) => { + if (pending.child.exitCode !== null || pending.child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => pending.child.once("exit", () => resolve())); + }), + ]).then(() => undefined); + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS); + timer.unref(); + }); + if ((await Promise.race([finished.then(() => false), timedOut])) === true) { + for (const worker of workers) { + try { + process.kill(worker.pid, "SIGKILL"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error; + } + } + for (const pending of this.#pending.values()) pending.child.kill("SIGKILL"); + await finished; + } + if (timer) clearTimeout(timer); + this.#detachState(); + } + + /** Forget workers without stopping them when this server is replaced. */ + detach(): void { + if (this.#detached) return; + this.#detached = true; + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("Experimental server was replaced")); + } + this.#detachState(); + } + + #launch(metadata: JsonlSessionMetadata): Promise { + const sessionKey = metadata.path; + const peerId = `worker-${randomUUID()}`; + const token = randomUUID(); + const launch = createExperimentalSessionWorkerLaunchSpec(metadata, { sessionDir: this.#sessionDir }); + let child: ChildProcess; + try { + child = spawn(launch.command, launch.args, { + cwd: launch.cwd, + detached: true, + env: { + ...launch.env, + [SESSION_WORKER_CONTROL_ADDRESS_ENV]: this.#coordinator.controlPath, + [SESSION_WORKER_CONTROL_TOKEN_ENV]: token, + [SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(sessionKey).toString("base64url"), + [SESSION_WORKER_COORDINATED_ENV]: "1", + [SESSION_WORKER_PEER_ID_ENV]: peerId, + }, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + } catch (error) { + return Promise.reject(error); + } + let resolve!: (worker: WorkerRecord) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + const timer = setTimeout( + () => this.#failPending(sessionKey, new Error("Session worker startup timed out")), + WORKER_STARTUP_TIMEOUT_MS, + ); + timer.unref(); + const pending = { sessionKey, peerId, token, child, timer, promise, resolve, reject }; + this.#pending.set(sessionKey, pending); + child.once("error", (error) => this.#failPending(sessionKey, error)); + child.once("exit", (code, signal) => this.#childExited(pending, code, signal)); + return promise; + } + + #handleCoordinatorEvent(event: CoordinatorServerEvent): void { + if (this.#detached) return; + if (event.type === "peer_disconnected") { + this.#markDiscovered(event.peerId); + const worker = this.#workersByPeer.get(event.peerId); + if (worker) { + this.#removeWorker( + worker, + worker.expectedStop + ? undefined + : new Error(`Session worker ${worker.metadata.id} disconnected unexpectedly`), + ); + } + const pending = this.#pendingPeer(event.peerId); + if (pending) this.#failPending(pending.sessionKey, new Error("Session worker disconnected during startup")); + return; + } + if (event.type !== "message" || !Check(WorkerMessageSchema, event.payload)) return; + const message: WorkerMessage = event.payload; + if (message.type === "worker_failed") { + const pending = this.#pending.get(message.sessionKey); + if (pending?.peerId === event.from && pending.token === message.token) { + this.#failPending(message.sessionKey, new Error(`Session worker failed: ${message.message}`)); + } + return; + } + this.#recordReadyWorker(event.from, message); + } + + #recordReadyWorker(peerId: string, message: Extract): void { + if ( + message.sessionKey !== message.metadata.path || + message.sessionId !== message.metadata.id || + !isAbsolute(message.metadata.cwd) || + !isAbsolute(message.metadata.path) + ) { + return; + } + this.#markDiscovered(peerId); + const existing = this.#workersBySession.get(message.sessionKey); + if (existing && existing.peerId !== peerId) { + void this.#coordinator.send(peerId, { type: "shutdown" }).catch(() => {}); + return; + } + const pending = this.#pending.get(message.sessionKey); + if ( + pending && + (pending.peerId !== peerId || pending.token !== message.token || pending.child.pid !== message.pid) + ) { + return; + } + if (existing) return; + let resolveTerminated!: (error: Error | undefined) => void; + const terminated = new Promise((resolve) => { + resolveTerminated = resolve; + }); + const worker: WorkerRecord = { + peerId, + metadata: message.metadata, + pid: message.pid, + terminated, + resolveTerminated, + expectedStop: false, + }; + this.#workersBySession.set(message.sessionKey, worker); + this.#workersByPeer.set(peerId, worker); + this.workerPids.set(message.sessionId, message.pid); + if (pending) { + this.#pending.delete(message.sessionKey); + clearTimeout(pending.timer); + pending.resolve(worker); + } + } + + #childExited(pending: PendingLaunch, code: number | null, signal: NodeJS.Signals | null): void { + if (this.#pending.get(pending.sessionKey) === pending) { + this.#failPending( + pending.sessionKey, + new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`), + ); + return; + } + const worker = this.#workersByPeer.get(pending.peerId); + if (worker) { + this.#removeWorker( + worker, + worker.expectedStop + ? undefined + : new Error(`Session worker ${worker.metadata.id} exited unexpectedly (${signal ?? code ?? "unknown"})`), + ); + } + } + + #failPending(sessionKey: string, error: Error): void { + const pending = this.#pending.get(sessionKey); + if (!pending) return; + this.#pending.delete(sessionKey); + clearTimeout(pending.timer); + if (pending.child.exitCode === null && pending.child.signalCode === null) pending.child.kill("SIGKILL"); + pending.reject(error); + } + + #removeWorker(worker: WorkerRecord, error: Error | undefined): void { + if (this.#workersByPeer.get(worker.peerId) !== worker) return; + this.#workersByPeer.delete(worker.peerId); + this.#workersBySession.delete(worker.metadata.path); + if (this.workerPids.get(worker.metadata.id) === worker.pid) this.workerPids.delete(worker.metadata.id); + worker.resolveTerminated(error); + } + + #pendingPeer(peerId: string): PendingLaunch | undefined { + return [...this.#pending.values()].find((pending) => pending.peerId === peerId); + } + + #markDiscovered(peerId: string): void { + if (!this.#discoveryPeers?.delete(peerId) || this.#discoveryPeers.size !== 0) return; + this.#resolveDiscovery?.(); + } + + #detachState(): void { + this.#removeListener(); + this.#pending.clear(); + this.#workersByPeer.clear(); + this.#workersBySession.clear(); + this.workerPids.clear(); + this.#discoveryPeers = undefined; + this.#resolveDiscovery?.(); + this.#resolveDiscovery = undefined; + } +} diff --git a/packages/coding-agent/src/cli/experimental/coordinator-client.ts b/packages/coding-agent/src/cli/experimental/coordinator-client.ts new file mode 100644 index 000000000..177048a1f --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinator-client.ts @@ -0,0 +1,282 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createConnection, type Socket } from "node:net"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Type, { type Static } from "typebox"; +import { Check } from "typebox/value"; + +const COORDINATOR_PROTOCOL_VERSION = 1; +const COORDINATOR_START_TIMEOUT_MS = 10_000; +const COORDINATOR_RETRY_MS = 10; +const MAX_CONTROL_LINE_BYTES = 1024 * 1024; + +const CoordinatorMessageSchema = Type.Union([ + Type.Object({ + type: Type.Literal("server_registered"), + serverConnectionId: Type.String(), + peers: Type.Array(Type.String()), + }), + Type.Object({ type: Type.Literal("server_replaced") }), + Type.Object({ type: Type.Literal("peer_connected"), peerId: Type.String() }), + Type.Object({ type: Type.Literal("peer_disconnected"), peerId: Type.String() }), + Type.Object({ type: Type.Literal("message"), from: Type.String(), payload: Type.Unknown() }), +]); +type CoordinatorMessage = Static; + +export type CoordinatorServerEvent = + | { readonly type: "peer_connected"; readonly peerId: string } + | { readonly type: "peer_disconnected"; readonly peerId: string } + | { readonly type: "message"; readonly from: string; readonly payload: unknown }; + +export interface CoordinatorServerOptions { + readonly controlPath: string; + readonly endpoint: string; + readonly serverConnectionId?: string; +} + +/** The server-side endpoint of the coordinator's intentionally opaque message router. */ +export class CoordinatorServer { + readonly serverConnectionId: string; + readonly replaced: Promise; + readonly peerIds = new Set(); + readonly #controlPath: string; + readonly #endpoint: string; + readonly #listeners = new Set<(event: CoordinatorServerEvent) => void>(); + #socket?: Socket; + #registered = false; + #closed = false; + #replacedValue = false; + #resolveRegistered?: () => void; + #rejectRegistered?: (error: Error) => void; + #resolveReplaced!: () => void; + + constructor(options: CoordinatorServerOptions) { + this.serverConnectionId = options.serverConnectionId ?? randomUUID(); + this.#controlPath = options.controlPath; + this.#endpoint = options.endpoint; + this.replaced = new Promise((resolve) => { + this.#resolveReplaced = resolve; + }); + } + + get controlPath(): string { + return this.#controlPath; + } + + get wasReplaced(): boolean { + return this.#replacedValue; + } + + onEvent(listener: (event: CoordinatorServerEvent) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + async connect(): Promise { + if (this.#socket) throw new Error("Coordinator server is already connected"); + const socket = await connectSocket(this.#controlPath); + this.#socket = socket; + attachJsonLineReader(socket, (message) => this.#handleMessage(message)); + const registered = new Promise((resolve, reject) => { + this.#resolveRegistered = resolve; + this.#rejectRegistered = reject; + }); + socket.once("close", () => this.#disconnected(new Error("Coordinator connection closed"))); + socket.once("error", (error) => this.#disconnected(error)); + await writeJsonLine(socket, { + type: "register_server", + protocol: COORDINATOR_PROTOCOL_VERSION, + serverConnectionId: this.serverConnectionId, + endpoint: this.#endpoint, + }); + await registered; + } + + send(peerId: string, payload: unknown): Promise { + return this.#write({ type: "send", to: peerId, payload }); + } + + broadcast(payload: unknown): Promise { + return this.#write({ type: "broadcast", payload }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#socket?.destroy(); + this.#socket = undefined; + this.peerIds.clear(); + this.#rejectRegistered?.(new Error("Coordinator server closed")); + this.#resolveRegistered = undefined; + this.#rejectRegistered = undefined; + } + + async #write(message: unknown): Promise { + if (!this.#registered || !this.#socket || this.#closed) throw new Error("Coordinator server is not connected"); + await writeJsonLine(this.#socket, message); + } + + #handleMessage(value: unknown): void { + if (!Check(CoordinatorMessageSchema, value)) { + this.#socket?.destroy(new Error("Coordinator sent an invalid message")); + return; + } + const message: CoordinatorMessage = value; + if (message.type === "server_registered") { + if (message.serverConnectionId !== this.serverConnectionId) { + this.#socket?.destroy(new Error("Coordinator returned an invalid server registration")); + return; + } + for (const peerId of message.peers) this.peerIds.add(peerId); + this.#registered = true; + this.#resolveRegistered?.(); + this.#resolveRegistered = undefined; + this.#rejectRegistered = undefined; + return; + } + if (message.type === "server_replaced") { + this.#markReplaced(); + return; + } + if (message.type === "peer_connected") { + this.peerIds.add(message.peerId); + this.#emit({ type: "peer_connected", peerId: message.peerId }); + return; + } + if (message.type === "peer_disconnected") { + this.peerIds.delete(message.peerId); + this.#emit({ type: "peer_disconnected", peerId: message.peerId }); + return; + } + if (message.type === "message") { + this.#emit({ type: "message", from: message.from, payload: message.payload }); + return; + } + this.#socket?.destroy(new Error("Coordinator sent an unsupported message")); + } + + #emit(event: CoordinatorServerEvent): void { + for (const listener of this.#listeners) listener(event); + } + + #disconnected(error: Error): void { + this.#socket = undefined; + if (this.#closed) return; + this.#rejectRegistered?.(error); + this.#resolveRegistered = undefined; + this.#rejectRegistered = undefined; + this.#markReplaced(); + } + + #markReplaced(): void { + if (this.#replacedValue) return; + this.#replacedValue = true; + this.#resolveReplaced(); + } +} + +export function getExperimentalCoordinatorControlPath(serverId: string, socketDirectory: string): string { + return join(socketDirectory, `.c-${serverId.slice(0, 12)}.sock`); +} + +export function getExperimentalServerPath(socketDirectory: string): string { + return join(socketDirectory, `.s-${randomUUID().slice(0, 12)}.sock`); +} + +export interface CoordinatorStartupLease { + close(): void; +} + +export async function ensureExperimentalCoordinator( + publicPath: string, + controlPath: string, + coordinatorUrl = new URL( + import.meta.url.endsWith(".js") ? "coordinator-process.js" : "coordinator-process.ts", + import.meta.url, + ), +): Promise { + const existing = await tryConnect(controlPath); + if (existing) return { close: () => existing.destroy() }; + const args = [ + ...(coordinatorUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : []), + fileURLToPath(coordinatorUrl), + publicPath, + controlPath, + ]; + const child = spawn(process.execPath, args, { + cwd: process.cwd(), + detached: true, + env: process.env, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + const deadline = Date.now() + COORDINATOR_START_TIMEOUT_MS; + while (true) { + const socket = await tryConnect(controlPath); + if (socket) return { close: () => socket.destroy() }; + if (child.exitCode !== null || child.signalCode !== null) throw new Error("Coordinator exited during startup"); + if (Date.now() >= deadline) throw new Error("Timed out waiting for coordinator startup"); + await new Promise((resolve) => setTimeout(resolve, COORDINATOR_RETRY_MS)); + } +} + +function connectSocket(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(path); + const onConnect = (): void => { + socket.off("error", onError); + resolve(socket); + }; + const onError = (error: Error): void => { + socket.off("connect", onConnect); + reject(error); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + }); +} + +async function tryConnect(path: string): Promise { + try { + return await connectSocket(path); + } catch (error) { + const code = error instanceof Error && "code" in error ? error.code : undefined; + if (code === "ENOENT" || code === "ECONNREFUSED") return undefined; + throw error; + } +} + +function attachJsonLineReader(socket: Socket, onMessage: (message: unknown) => void): void { + let buffered = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffered += chunk; + if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) { + socket.destroy(new Error("Coordinator message is too large")); + return; + } + while (true) { + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + try { + onMessage(JSON.parse(line)); + } catch { + socket.destroy(new Error("Coordinator sent invalid JSON")); + return; + } + } + }); +} + +function writeJsonLine(socket: Socket, message: unknown): Promise { + return new Promise((resolve, reject) => { + socket.write(`${JSON.stringify(message)}\n`, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/packages/coding-agent/src/cli/experimental/coordinator-process.ts b/packages/coding-agent/src/cli/experimental/coordinator-process.ts new file mode 100644 index 000000000..c30ea8351 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinator-process.ts @@ -0,0 +1,329 @@ +import { chmod, lstat, unlink } from "node:fs/promises"; +import { createConnection, createServer, type Server, type Socket } from "node:net"; + +// This process is intentionally a transport shim. It depends only on Node +// built-ins and never interprets Pi, session, worker, or lifecycle payloads. + +const COORDINATOR_PROTOCOL_VERSION = 1; +const MAX_CONTROL_LINE_BYTES = 1024 * 1024; +const EMPTY_STARTUP_GRACE_MS = 30_000; +const EMPTY_SHUTDOWN_GRACE_MS = 250; + +interface ServerPeer { + readonly serverConnectionId: string; + readonly endpoint: string; + readonly socket: Socket; +} + +interface RoutedPeer { + readonly peerId: string; + readonly socket: Socket; +} + +interface ControlMessage { + readonly type: string; + readonly protocol?: unknown; + readonly serverConnectionId?: unknown; + readonly endpoint?: unknown; + readonly peerId?: unknown; + readonly to?: unknown; + readonly payload?: unknown; +} + +const publicPath = process.argv[2]; +const controlPath = process.argv[3]; +if (!publicPath || !controlPath) throw new Error("Coordinator requires public and control socket paths"); + +const peers = new Map(); +const controlConnections = new Set(); +const publicConnections = new Map(); +let currentServer: ServerPeer | undefined; +let shuttingDown = false; +let emptyTimer: NodeJS.Timeout | undefined; + +const publicServer = createServer((socket) => acceptPublicConnection(socket)); +const controlServer = createServer((socket) => acceptControlConnection(socket)); + +async function main(): Promise { + await removeStaleSocket(controlPath); + await removeStaleSocket(publicPath); + try { + await listen(controlServer, controlPath); + await restrictSocket(controlPath); + await listen(publicServer, publicPath); + await restrictSocket(publicPath); + } catch (error) { + await Promise.all([cleanupSocket(controlPath), cleanupSocket(publicPath)]); + throw error; + } + scheduleEmptyShutdown(EMPTY_STARTUP_GRACE_MS); +} + +function acceptControlConnection(socket: Socket): void { + if (shuttingDown) { + socket.destroy(); + return; + } + controlConnections.add(socket); + let server: ServerPeer | undefined; + let peer: RoutedPeer | undefined; + attachJsonLineReader(socket, (message) => { + if (!server && !peer) { + if (message.type === "register_server") { + server = registerServer(socket, message); + return; + } + if (message.type === "register_peer") { + peer = registerPeer(socket, message); + return; + } + throw new Error("Coordinator connection did not register a role"); + } + if (server) { + if (server === currentServer) handleRoutedMessage("server", message); + return; + } + handleRoutedMessage(peer!.peerId, message); + }); + const disconnect = (): void => { + controlConnections.delete(socket); + if (server && currentServer === server) currentServer = undefined; + if (peer && peers.get(peer.peerId) === peer) { + peers.delete(peer.peerId); + if (currentServer) writeJsonLine(currentServer.socket, { type: "peer_disconnected", peerId: peer.peerId }); + } + checkEmpty(); + }; + socket.once("close", disconnect); + socket.once("error", () => socket.destroy()); +} + +function registerServer(socket: Socket, message: ControlMessage): ServerPeer { + if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol"); + if (typeof message.serverConnectionId !== "string" || message.serverConnectionId.length === 0) { + throw new Error("Coordinator serverConnectionId must be a string"); + } + if (typeof message.endpoint !== "string" || message.endpoint.length === 0) { + throw new Error("Coordinator endpoint must be a string"); + } + const { serverConnectionId, endpoint } = message; + const server = { serverConnectionId, endpoint, socket }; + const previous = currentServer; + currentServer = server; + cancelEmptyShutdown(); + writeJsonLine(socket, { + type: "server_registered", + serverConnectionId, + peers: [...peers.keys()], + }); + if (previous && previous !== server) { + closePublicConnections(); + writeJsonLine(previous.socket, { type: "server_replaced" }); + } + return server; +} + +function registerPeer(socket: Socket, message: ControlMessage): RoutedPeer { + if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol"); + if (typeof message.peerId !== "string" || message.peerId.length === 0) { + throw new Error("Coordinator peerId must be a string"); + } + const { peerId } = message; + if (peerId === "server" || peers.has(peerId)) throw new Error(`Coordinator peer is already connected: ${peerId}`); + const peer = { peerId, socket }; + peers.set(peerId, peer); + cancelEmptyShutdown(); + writeJsonLine(socket, { type: "peer_registered", peerId }); + if (currentServer) { + writeJsonLine(currentServer.socket, { type: "peer_connected", peerId }); + } + return peer; +} + +function handleRoutedMessage(from: string, message: ControlMessage): void { + if (message.type === "send") { + if (typeof message.to !== "string" || message.to.length === 0) { + throw new Error("Coordinator message target must be a string"); + } + const { to } = message; + const target = to === "server" ? currentServer?.socket : peers.get(to)?.socket; + if (target) writeJsonLine(target, { type: "message", from, payload: message.payload }); + return; + } + if (message.type === "broadcast") { + if (from !== "server") throw new Error("Only the current server may broadcast"); + for (const peer of peers.values()) { + writeJsonLine(peer.socket, { type: "message", from, payload: message.payload }); + } + return; + } + throw new Error(`Unknown coordinator routing message: ${message.type}`); +} + +function acceptPublicConnection(client: Socket): void { + if (shuttingDown || !currentServer) { + client.destroy(); + return; + } + cancelEmptyShutdown(); + const upstream = createConnection(currentServer.endpoint); + publicConnections.set(client, upstream); + let finalized = false; + const finalize = (): void => { + if (finalized) return; + finalized = true; + publicConnections.delete(client); + checkEmpty(); + }; + upstream.once("connect", () => { + client.pipe(upstream); + upstream.pipe(client); + }); + upstream.once("error", () => client.destroy()); + upstream.once("close", () => { + client.destroy(); + finalize(); + }); + client.once("error", () => upstream.destroy()); + client.once("close", () => { + upstream.destroy(); + finalize(); + }); +} + +function closePublicConnections(): void { + for (const [client, upstream] of publicConnections) { + client.destroy(); + upstream.destroy(); + } + publicConnections.clear(); +} + +function checkEmpty(): void { + if (shuttingDown || currentServer || peers.size > 0 || publicConnections.size > 0 || controlConnections.size > 0) { + cancelEmptyShutdown(); + return; + } + scheduleEmptyShutdown(EMPTY_SHUTDOWN_GRACE_MS); +} + +function scheduleEmptyShutdown(delayMs: number): void { + if (emptyTimer || shuttingDown) return; + emptyTimer = setTimeout(() => { + emptyTimer = undefined; + if (!currentServer && peers.size === 0 && publicConnections.size === 0 && controlConnections.size === 0) { + void shutdownCoordinator(); + } + }, delayMs); + emptyTimer.unref(); +} + +function cancelEmptyShutdown(): void { + if (!emptyTimer) return; + clearTimeout(emptyTimer); + emptyTimer = undefined; +} + +async function shutdownCoordinator(): Promise { + if (shuttingDown) return; + shuttingDown = true; + cancelEmptyShutdown(); + closePublicConnections(); + for (const connection of controlConnections) connection.destroy(); + await Promise.all([closeServer(publicServer), closeServer(controlServer)]); + await Promise.all([cleanupSocket(publicPath), cleanupSocket(controlPath)]); + process.exit(0); +} + +function writeJsonLine(socket: Socket, message: unknown): void { + if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`); +} + +function attachJsonLineReader(socket: Socket, onMessage: (message: ControlMessage) => void): void { + let buffered = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffered += chunk; + if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) { + socket.destroy(new Error("Coordinator message is too large")); + return; + } + while (true) { + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + try { + const message = JSON.parse(line) as ControlMessage | null; + if (typeof message?.type !== "string") throw new Error("Coordinator message must have a type"); + onMessage(message); + } catch (error) { + socket.destroy(error instanceof Error ? error : new Error(String(error))); + return; + } + } + }); +} + +function listen(server: Server, path: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(path); + }); +} + +function closeServer(server: Server): Promise { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); +} + +async function restrictSocket(path: string): Promise { + if (process.platform !== "win32") await chmod(path, 0o600); +} + +async function removeStaleSocket(path: string): Promise { + try { + const stats = await lstat(path); + if (!stats.isSocket()) throw new Error(`Coordinator path is not a socket: ${path}`); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw error; + } + const live = await new Promise((resolve, reject) => { + const socket = createConnection(path); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + socket.destroy(); + if (error.code === "ECONNREFUSED" || error.code === "ENOENT") resolve(false); + else reject(error); + }); + }); + if (live) throw new Error(`Coordinator socket is already active: ${path}`); + await cleanupSocket(path); +} + +async function cleanupSocket(path: string): Promise { + if (process.platform === "win32") return; + await unlink(path).catch((error: unknown) => { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + }); +} + +process.once("SIGINT", () => void shutdownCoordinator()); +process.once("SIGTERM", () => void shutdownCoordinator()); +void main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 297a50487..8e2c0a43a 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -2,23 +2,27 @@ import { randomUUID } from "node:crypto"; import { chmod, lstat, mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -import { setTimeout as delay } from "node:timers/promises"; import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { PiClient } from "@earendil-works/pi-client"; -import { requestServerDrain } from "@earendil-works/pi-client/control"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { isServerId } from "@earendil-works/pi-protocol"; -import type { PiServer, PiServerHost } from "@earendil-works/pi-server"; +import type { HostedHarnessHandle, PiServer, PiServerHost } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; import { getAgentDir } from "../../config.ts"; import { resolvePath } from "../../utils/paths.ts"; import type { ClientCommand } from "./commands/client.ts"; +import { CoordinatedSessionWorkers } from "./coordinated-session-workers.ts"; +import { + CoordinatorServer, + type CoordinatorStartupLease, + ensureExperimentalCoordinator, + getExperimentalCoordinatorControlPath, + getExperimentalServerPath, +} from "./coordinator-client.ts"; import { acquireExperimentalServerProfile } from "./server-profile.ts"; import { startExperimentalSessionWorker } from "./session-worker.ts"; -const SOCKET_RELEASE_TIMEOUT_MS = 10_000; -const SOCKET_RELEASE_POLL_MS = 10; const EXPERIMENTAL_SOCKET_ROOT = "/tmp"; export interface ExperimentalServer { @@ -47,7 +51,7 @@ export interface StartExperimentalServerOptions { readonly sessionDir?: string; } -export interface StartExperimentalServerGenerationOptions { +export interface StartExperimentalCoordinatedServerOptions { /** Persistent profile directory. Defaults to ~/.pi/server. */ readonly directory?: string; /** Physical socket directory. Defaults to a short, private per-user runtime directory. */ @@ -65,18 +69,21 @@ export function resolveExperimentalSessionDirectory(sessionDir?: string): string return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions")); } +interface ExperimentalWorkerController { + readonly workerPids: ReadonlyMap; + readonly trackedSessions: readonly JsonlSessionMetadata[]; + createHarness(metadata: JsonlSessionMetadata): Promise; +} + /** Start the experimental durable list-and-attach server composition. */ export async function startExperimentalServer( options: StartExperimentalServerOptions = {}, ): Promise { - const serverId = options.serverId ?? randomUUID(); const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); - const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() }); - const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); - const workerPids = new Map(); - const host: PiServerHost = { - sessions: repo, + const controller: ExperimentalWorkerController = { + workerPids, + trackedSessions: [], createHarness: async (metadata) => { const sessionId = metadata.id; const worker = await startExperimentalSessionWorker(metadata, { sessionDir }); @@ -96,6 +103,27 @@ export async function startExperimentalServer( }; }, }; + return startExperimentalServerBackend(options, controller); +} + +async function startExperimentalServerBackend( + options: StartExperimentalServerOptions, + workers: ExperimentalWorkerController, +): Promise { + const serverId = options.serverId ?? randomUUID(); + const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); + const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() }); + const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); + const host: PiServerHost = { + sessions: { + list: async () => { + const sessions = new Map((await repo.list()).map((metadata) => [metadata.path, metadata])); + for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata); + return [...sessions.values()]; + }, + }, + createHarness: (metadata) => workers.createHarness(metadata), + }; let socketPath = options.path; if (socketPath === undefined) { const socketDirectory = options.directory ?? getExperimentalSocketDirectory(); @@ -137,72 +165,100 @@ export async function startExperimentalServer( sessionDir, socketPath, server, - workerPids, + workerPids: workers.workerPids, closed, close() { - if (closePromise === undefined) - closePromise = server.close().then( - () => closed, - () => closed, - ); + closePromise ??= server.close().then( + () => closed, + () => closed, + ); return closePromise; }, }; } -/** Start the experimental local server, replacing an existing generation for the same profile. */ -export async function startExperimentalServerGeneration( - options: StartExperimentalServerGenerationOptions = {}, +/** Start a replaceable experimental server behind the stable coordinator endpoint. */ +export async function startExperimentalCoordinatedServer( + options: StartExperimentalCoordinatedServerOptions = {}, ): Promise { const directory = options.directory ?? join(homedir(), ".pi", "server"); const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory(); const { serverId, release } = await acquireExperimentalServerProfile(directory); - let runtime: ExperimentalServer; + let backend: ExperimentalServer | undefined; + let coordinator: CoordinatorServer | undefined; + let startupLease: CoordinatorStartupLease | undefined; + let workers: CoordinatedSessionWorkers | undefined; + let released = false; try { await ensurePrivateSocketDirectory(socketDirectory); const socketPath = getUnixSocketPath(serverId, socketDirectory); - await drainExistingGeneration(serverId, socketPath); - runtime = await startExperimentalServer({ - directory, - path: socketPath, - serverId, - sessionDir: options.sessionDir, - }); - } catch (error) { - try { - await release(); - } catch (releaseError) { - throw new AggregateError([error, releaseError], "Server generation failed and launcher lock release failed"); - } - throw error; - } + const controlPath = getExperimentalCoordinatorControlPath(serverId, socketDirectory); + const serverPath = getExperimentalServerPath(socketDirectory); + startupLease = await ensureExperimentalCoordinator(socketPath, controlPath); + coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath }); + const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); + workers = new CoordinatedSessionWorkers(coordinator, sessionDir); + backend = await startExperimentalServerBackend( + { path: serverPath, serverId, sessionDir: options.sessionDir }, + workers, + ); + await coordinator.connect(); + startupLease.close(); + startupLease = undefined; + await workers.discover(coordinator.peerIds); - try { + const activeBackend = backend; + const activeCoordinator = coordinator; + const activeWorkers = workers; + void activeCoordinator.replaced + .then(async () => { + activeWorkers.detach(); + await activeBackend.close(); + }) + .finally(() => activeCoordinator.close()) + .catch(() => {}); + let closePromise: Promise | undefined; + const runtime: ExperimentalServer = { + serverId, + sessionDir: activeBackend.sessionDir, + socketPath, + server: activeBackend.server, + workerPids: activeWorkers.workerPids, + closed: activeBackend.closed, + close() { + closePromise ??= (async () => { + try { + await activeBackend.close(); + } finally { + try { + if (activeCoordinator.wasReplaced) activeWorkers.detach(); + else await activeWorkers.shutdown(); + } finally { + activeCoordinator.close(); + } + } + })(); + return closePromise; + }, + }; + released = true; await release(); + return runtime; } catch (error) { - try { - await runtime.close(); - } catch (closeError) { - throw new AggregateError([error, closeError], "Launcher lock release and server cleanup failed"); + startupLease?.close(); + if (coordinator?.wasReplaced) workers?.detach(); + const cleanup = await Promise.allSettled([ + backend?.close(), + coordinator?.wasReplaced ? undefined : workers?.shutdown(), + Promise.resolve(coordinator?.close()), + released ? undefined : release(), + ]); + const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (cleanupErrors.length > 0) { + throw new AggregateError([error, ...cleanupErrors], "Coordinated server startup and cleanup failed"); } throw error; } - return runtime; -} - -async function drainExistingGeneration(serverId: string, socketPath: string): Promise { - try { - await requestServerDrain({ - serverId, - transportFactory: createUnixTransportFactory({ path: socketPath }), - }); - await waitForSocketRelease(socketPath); - } catch (error) { - // At this launcher boundary, a missing or refused stable socket proves that - // no generation accepted the drain request. All ambiguous failures propagate. - if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ECONNREFUSED")) return; - throw error; - } } /** Discover local servers, then list sessions or attach to one selected session. */ @@ -269,20 +325,6 @@ function routeFromExplicitPath(path: string): UnixServerRoute { return { serverId, path }; } -async function waitForSocketRelease(path: string): Promise { - const deadline = Date.now() + SOCKET_RELEASE_TIMEOUT_MS; - while (true) { - try { - await lstat(path); - } catch (error) { - if (hasErrorCode(error, "ENOENT")) return; - throw error; - } - if (Date.now() >= deadline) throw new Error(`Timed out waiting for server socket to close: ${path}`); - await delay(SOCKET_RELEASE_POLL_MS); - } -} - function getExperimentalSocketDirectory(): string { if (process.platform === "win32" || typeof process.getuid !== "function") { throw new Error("Experimental Unix server transport requires a POSIX user ID"); @@ -299,14 +341,3 @@ async function ensurePrivateSocketDirectory(directory: string): Promise { throw new Error(`Unix socket directory is not owned by the current user: ${directory}`); await chmod(directory, 0o700); } - -function hasErrorCode(error: unknown, code: string): boolean { - let current = error; - const seen = new Set(); - while (current instanceof Error && !seen.has(current)) { - seen.add(current); - if ("code" in current && current.code === code) return true; - current = current.cause; - } - return false; -} diff --git a/packages/coding-agent/src/cli/experimental/session-worker-process.ts b/packages/coding-agent/src/cli/experimental/session-worker-process.ts index e5fba0d14..e8e608504 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker-process.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker-process.ts @@ -1,3 +1,4 @@ +import { createConnection, type Socket } from "node:net"; import { isAbsolute } from "node:path"; import { AgentHarness, @@ -7,22 +8,163 @@ import { type Session, } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import lockfile from "proper-lockfile"; +import Type, { type Static } from "typebox"; +import { Check } from "typebox/value"; import { ModelRuntime } from "../../core/model-runtime.ts"; +import { + SESSION_WORKER_CONTROL_ADDRESS_ENV, + SESSION_WORKER_CONTROL_TOKEN_ENV, + SESSION_WORKER_COORDINATED_ENV, + SESSION_WORKER_PEER_ID_ENV, + SESSION_WORKER_SESSION_KEY_ENV, +} from "./session-worker.ts"; -// Prototype-only process control. Agent operations will use the shared -// transport-independent protocol rather than additional ad hoc IPC messages. +// Process lifecycle control only. Agent operations use the shared +// transport-independent protocol rather than this channel. export type SessionWorkerCommand = { type: "shutdown" }; export type SessionWorkerEvent = | { type: "ready"; sessionId: string; pid: number } | { type: "failed"; message: string }; -function send(event: SessionWorkerEvent): Promise { - return new Promise((resolve, reject) => { - if (!process.send) { - reject(new Error("Session worker requires an IPC channel")); +const WorkerMetadataSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + createdAt: Type.Integer(), + storageVersion: Type.Integer(), + cwd: Type.String(), + path: Type.String(), + modifiedAt: Type.Number(), + parentSessionId: Type.Optional(Type.String()), + legacyParentSessionPath: Type.Optional(Type.String()), +}); +const CoordinatorInputSchema = Type.Union([ + Type.Object({ type: Type.Literal("peer_registered"), peerId: Type.String() }), + Type.Object({ type: Type.Literal("message"), from: Type.Literal("server"), payload: Type.Unknown() }), +]); +const WorkerCommandSchema = Type.Union([ + Type.Object({ type: Type.Literal("shutdown") }), + Type.Object({ type: Type.Literal("discover_workers") }), +]); +type CoordinatorInput = Static; +type WorkerCommand = Static; + +type SessionWorkerControlEvent = + | { + type: "register_worker"; + protocol: 1; + token: string; + sessionKey: string; + sessionId: string; + pid: number; + } + | { + type: "worker_ready"; + token: string; + sessionKey: string; + sessionId: string; + pid: number; + metadata: JsonlSessionMetadata; + } + | { type: "worker_failed"; token: string; sessionKey: string; message: string }; + +interface WorkerControl { + readonly coordinated: boolean; + readonly messages: AsyncIterable; + readonly socket: Socket; + send(event: SessionWorkerControlEvent): Promise; +} + +let failureControl: WorkerControl | undefined; + +async function connectControl(): Promise { + const address = process.env[SESSION_WORKER_CONTROL_ADDRESS_ENV]; + const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]; + const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV]; + if (!address || !token || !encodedSessionKey) throw new Error("Session worker requires a control address"); + const socket = createConnection(address); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const messages = createJsonLineMessages(socket); + const coordinated = process.env[SESSION_WORKER_COORDINATED_ENV] === "1"; + if (coordinated) { + const peerId = process.env[SESSION_WORKER_PEER_ID_ENV]; + if (!peerId) throw new Error("Coordinated session worker requires a peer ID"); + await writeJsonLine(socket, { type: "register_peer", protocol: 1, peerId }); + const registered = await messages[Symbol.asyncIterator]().next(); + if ( + registered.done || + !Check(CoordinatorInputSchema, registered.value) || + registered.value.type !== "peer_registered" + ) { + throw new Error("Coordinator rejected the session worker registration"); + } + } + return { + coordinated, + messages, + socket, + send: (event) => writeJsonLine(socket, coordinated ? { type: "send", to: "server", payload: event } : event), + }; +} + +async function readCommands(control: WorkerControl, onShutdown: () => void, onDiscovery: () => void): Promise { + for await (const value of control.messages) { + if (!control.coordinated) { + if (Check(WorkerCommandSchema, value) && value.type === "shutdown") onShutdown(); + continue; + } + if (!Check(CoordinatorInputSchema, value)) { + control.socket.destroy(new Error("Coordinator sent an invalid worker message")); return; } - process.send(event, (error) => { + const message: CoordinatorInput = value; + if (message.type !== "message" || !Check(WorkerCommandSchema, message.payload)) continue; + const command: WorkerCommand = message.payload; + if (command.type === "shutdown") onShutdown(); + else onDiscovery(); + } +} + +function createJsonLineMessages(socket: Socket): AsyncIterable { + const queued: unknown[] = []; + const waiters: ((value: unknown) => void)[] = []; + let buffered = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffered += chunk; + while (true) { + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + try { + const value: unknown = JSON.parse(line); + const waiter = waiters.shift(); + if (waiter) waiter(value); + else queued.push(value); + } catch { + socket.destroy(new Error("Session worker received invalid control JSON")); + return; + } + } + }); + return { + [Symbol.asyncIterator]() { + return { + next: async () => { + const value = queued.shift() ?? (await new Promise((resolve) => waiters.push(resolve))); + return { done: false as const, value }; + }, + }; + }, + }; +} + +function writeJsonLine(socket: Socket, message: unknown): Promise { + return new Promise((resolve, reject) => { + socket.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve(); }); @@ -37,27 +179,10 @@ function parseMetadata(value: string | undefined): JsonlSessionMetadata { } catch (error) { throw new Error("Session worker received invalid session metadata", { cause: error }); } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + if (!Check(WorkerMetadataSchema, parsed) || !isAbsolute(parsed.cwd) || !isAbsolute(parsed.path)) { throw new Error("Session worker received invalid session metadata"); } - const candidate = parsed as Record; - if ( - typeof candidate.id !== "string" || - candidate.id.length === 0 || - !Number.isSafeInteger(candidate.createdAt) || - !Number.isSafeInteger(candidate.storageVersion) || - typeof candidate.cwd !== "string" || - !isAbsolute(candidate.cwd) || - typeof candidate.path !== "string" || - !isAbsolute(candidate.path) || - typeof candidate.modifiedAt !== "number" || - !Number.isFinite(candidate.modifiedAt) || - (candidate.parentSessionId !== undefined && typeof candidate.parentSessionId !== "string") || - (candidate.legacyParentSessionPath !== undefined && typeof candidate.legacyParentSessionPath !== "string") - ) { - throw new Error("Session worker received invalid session metadata"); - } - return candidate as unknown as JsonlSessionMetadata; + return parsed; } async function closeResources(resources: { @@ -65,6 +190,7 @@ async function closeResources(resources: { session?: Session; repo: JsonlSessionRepo; executionEnv: NodeExecutionEnv; + releaseOwnership: () => Promise; }): Promise { const errors: unknown[] = []; try { @@ -83,6 +209,11 @@ async function closeResources(resources: { } catch (error) { errors.push(error); } + try { + await resources.releaseOwnership(); + } catch (error) { + errors.push(error); + } if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed"); } @@ -92,7 +223,27 @@ async function run(): Promise { if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory"); const metadata = parseMetadata(process.argv[3]); const sessionId = metadata.id; + const control = await connectControl(); + const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!; + const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString(); + failureControl = control; + if (!control.coordinated) { + await control.send({ + type: "register_worker", + protocol: 1, + token, + sessionKey, + sessionId, + pid: process.pid, + }); + } + const releaseOwnership = await lockfile.lock(metadata.path, { + realpath: true, + stale: 2_000, + update: 1_000, + retries: { retries: 320, factor: 1, minTimeout: 25, maxTimeout: 25, maxRetryTime: 8_000 }, + }); const executionEnv = new NodeExecutionEnv({ cwd: metadata.cwd }); const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); let session: Session | undefined; @@ -105,7 +256,7 @@ async function run(): Promise { ({ harness } = await AgentHarness.create({ session, models: modelRuntime, model, tools: [], resources: {} })); } catch (error) { try { - await closeResources({ harness, session, repo, executionEnv }); + await closeResources({ harness, session, repo, executionEnv, releaseOwnership }); } catch (cleanupError) { throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed"); } @@ -114,7 +265,7 @@ async function run(): Promise { let closing: Promise | undefined; const close = (): Promise => { - closing ??= closeResources({ harness, repo, executionEnv }); + closing ??= closeResources({ harness, repo, executionEnv, releaseOwnership }); return closing; }; const closeAndExit = (): void => { @@ -127,15 +278,29 @@ async function run(): Promise { ); }; - process.on("message", (message: SessionWorkerCommand) => { - if (message?.type === "shutdown") closeAndExit(); - }); - process.once("disconnect", closeAndExit); + let ready = false; + const announce = (): void => { + if (!ready) return; + void control + .send({ + type: "worker_ready", + token, + sessionKey, + sessionId, + pid: process.pid, + metadata, + }) + .catch(() => closeAndExit()); + }; + void readCommands(control, closeAndExit, announce); + control.socket.once("close", closeAndExit); + control.socket.once("error", () => closeAndExit()); process.once("SIGTERM", closeAndExit); process.once("SIGINT", closeAndExit); try { - await send({ type: "ready", sessionId, pid: process.pid }); + ready = true; + await control.send({ type: "worker_ready", token, sessionKey, sessionId, pid: process.pid, metadata }); } catch (error) { try { await close(); @@ -148,6 +313,11 @@ async function run(): Promise { void run().catch(async (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - await send({ type: "failed", message }).catch(() => {}); + const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]; + const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV]; + if (token && encodedSessionKey) { + const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString(); + await failureControl?.send({ type: "worker_failed", token, sessionKey, message }).catch(() => {}); + } process.exit(1); }); diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index ceee791b9..f6e6cfe25 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -1,11 +1,35 @@ -import { type ChildProcess, fork } from "node:child_process"; -import { isAbsolute } from "node:path"; +import { type ChildProcess, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmod, unlink } from "node:fs/promises"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; +import Type, { type Static } from "typebox"; +import { Check } from "typebox/value"; import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts"; -const DEFAULT_STARTUP_TIMEOUT_MS = 5_000; +const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; const MAX_TIMER_DELAY_MS = 2_147_483_647; +const WorkerControlInputSchema = Type.Union([ + Type.Object({ type: Type.Literal("worker_failed"), message: Type.String() }), + Type.Object({ type: Type.Literal("worker_ready"), sessionId: Type.String(), pid: Type.Integer({ minimum: 1 }) }), + Type.Object({ + type: Type.Literal("register_worker"), + protocol: Type.Literal(1), + token: Type.String(), + sessionId: Type.String(), + pid: Type.Integer({ minimum: 1 }), + }), +]); +type WorkerControlInput = Static; +export const SESSION_WORKER_CONTROL_ADDRESS_ENV = "PI_SESSION_WORKER_CONTROL_ADDRESS"; +export const SESSION_WORKER_CONTROL_TOKEN_ENV = "PI_SESSION_WORKER_CONTROL_TOKEN"; +export const SESSION_WORKER_SESSION_KEY_ENV = "PI_SESSION_WORKER_SESSION_KEY_BASE64"; +export const SESSION_WORKER_COORDINATED_ENV = "PI_SESSION_WORKER_COORDINATED"; +export const SESSION_WORKER_PEER_ID_ENV = "PI_SESSION_WORKER_PEER_ID"; export interface ExperimentalSessionWorker { readonly sessionId: string; @@ -14,6 +38,13 @@ export interface ExperimentalSessionWorker { close(): Promise; } +export interface SessionWorkerLaunchSpec { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; +} + export interface StartExperimentalSessionWorkerOptions { readonly sessionDir: string; readonly startupTimeoutMs?: number; @@ -21,29 +52,74 @@ export interface StartExperimentalSessionWorkerOptions { readonly workerUrl?: URL; } +export function createExperimentalSessionWorkerLaunchSpec( + metadata: JsonlSessionMetadata, + options: Pick, +): SessionWorkerLaunchSpec { + if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); + const workerUrl = + options.workerUrl ?? + new URL( + import.meta.url.endsWith(".js") ? "session-worker-process.js" : "session-worker-process.ts", + import.meta.url, + ); + return { + command: process.execPath, + args: [ + ...(workerUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : []), + fileURLToPath(workerUrl), + options.sessionDir, + JSON.stringify(metadata), + ], + cwd: process.cwd(), + env: Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => (value === undefined ? [] : [[name, value]])), + ), + }; +} + export async function startExperimentalSessionWorker( metadata: JsonlSessionMetadata, options: StartExperimentalSessionWorkerOptions, ): Promise { - if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); - const sessionId = metadata.id; const startupTimeoutMs = validateTimeout(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs"); const shutdownTimeoutMs = validateTimeout( options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs", ); - const workerUrl = options.workerUrl ?? defaultWorkerUrl(); - const child = fork(workerUrl, [options.sessionDir, JSON.stringify(metadata)], { - execArgv: workerExecArgv(workerUrl), - stdio: ["ignore", "ignore", "inherit", "ipc"], - }); + const launch = createExperimentalSessionWorkerLaunchSpec(metadata, options); + const token = randomUUID(); + const controlId = randomUUID(); + const controlAddress = + process.platform === "win32" ? `\\\\.\\pipe\\pi-worker-${controlId}` : join(tmpdir(), `pi-w-${controlId}.sock`); + const control = await createWorkerControlServer(controlAddress, token, metadata.id); + let child: ChildProcess; + try { + child = spawn(launch.command, launch.args, { + cwd: launch.cwd, + detached: true, + env: { + ...launch.env, + [SESSION_WORKER_CONTROL_ADDRESS_ENV]: controlAddress, + [SESSION_WORKER_CONTROL_TOKEN_ENV]: token, + [SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(metadata.path).toString("base64url"), + }, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + } catch (error) { + await control.close(); + throw error; + } try { - const ready = await waitForReady(child, sessionId, startupTimeoutMs); - return new ChildSessionWorker(child, ready.sessionId, ready.pid, shutdownTimeoutMs); + const ready = await waitForReady(child, control, metadata.id, startupTimeoutMs); + return new SpawnedSessionWorker(child, control, ready.sessionId, ready.pid, shutdownTimeoutMs); } catch (error) { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); await waitForExit(child); + await control.close(); throw error; } } @@ -55,38 +131,163 @@ function validateTimeout(value: number, name: string): number { return value; } -function workerExecArgv(workerUrl: URL): string[] { - return workerUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : process.execArgv; +type WorkerControllerEvent = SessionWorkerEvent | { type: "hello"; token: string; sessionId: string; pid: number }; + +interface WorkerControlServer { + readonly connected: Promise; + readonly events: AsyncIterable; + send(message: SessionWorkerCommand): Promise; + close(): Promise; } -function defaultWorkerUrl(): URL { - return new URL( - import.meta.url.endsWith(".js") ? "session-worker-process.js" : "session-worker-process.ts", - import.meta.url, - ); +async function createWorkerControlServer( + address: string, + token: string, + expectedSessionId: string, +): Promise { + let accepted: Socket | undefined; + let resolveConnected!: (socket: Socket) => void; + const connected = new Promise((resolve) => { + resolveConnected = resolve; + }); + const messages: WorkerControllerEvent[] = []; + const waiters: ((message: WorkerControllerEvent) => void)[] = []; + const server = createServer((socket) => { + if (accepted) { + socket.destroy(); + return; + } + accepted = socket; + resolveConnected(socket); + attachJsonLineReader(socket, (value) => { + if (!Check(WorkerControlInputSchema, value)) { + socket.destroy(new Error("Session worker sent an invalid control message")); + return; + } + const message: WorkerControlInput = value; + let event: WorkerControllerEvent; + if (message.type === "worker_failed") { + event = { type: "failed", message: message.message }; + } else if (message.type === "worker_ready") { + event = { type: "ready", sessionId: message.sessionId, pid: message.pid }; + } else { + event = { + type: "hello", + token: message.token, + sessionId: message.sessionId, + pid: message.pid, + }; + } + if (event.type === "hello" && (event.token !== token || event.sessionId !== expectedSessionId)) { + socket.destroy(new Error("Session worker sent invalid control credentials")); + return; + } + const waiter = waiters.shift(); + if (waiter) waiter(event); + else messages.push(event); + }); + }); + await listen(server, address); + if (process.platform !== "win32") await chmod(address, 0o600); + + let closePromise: Promise | undefined; + return { + connected, + events: { + [Symbol.asyncIterator]() { + return { + next: async () => { + const message = + messages.shift() ?? + (await new Promise<(typeof messages)[number]>((resolve) => waiters.push(resolve))); + return { done: false as const, value: message }; + }, + }; + }, + }, + async send(message) { + const socket = await connected; + await new Promise((resolve, reject) => { + socket.write(`${JSON.stringify(message)}\n`, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + }, + close() { + closePromise ??= closeWorkerControl(server, accepted, address); + return closePromise; + }, + }; } -class ChildSessionWorker implements ExperimentalSessionWorker { +async function waitForReady( + child: ChildProcess, + control: WorkerControlServer, + expectedSessionId: string, + startupTimeoutMs: number, +): Promise> { + const events = control.events[Symbol.asyncIterator](); + const startup = (async () => { + const hello = (await events.next()).value; + if (hello.type !== "hello" || hello.sessionId !== expectedSessionId || hello.pid !== child.pid) { + throw new Error("Session worker reported an invalid identity"); + } + const event = (await events.next()).value; + if (event.type === "failed") throw new Error(`Session worker failed: ${event.message}`); + if (event.type !== "ready" || event.sessionId !== expectedSessionId || event.pid !== child.pid) { + throw new Error("Session worker reported an invalid identity"); + } + return event; + })(); + const exited = waitForExit(child).then(({ code, signal }) => { + throw new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`); + }); + let timeout: NodeJS.Timeout | undefined; + const timedOut = new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`Session worker startup timed out after ${startupTimeoutMs}ms`)), + startupTimeoutMs, + ); + timeout.unref(); + }); + try { + return await Promise.race([startup, exited, timedOut]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +class SpawnedSessionWorker implements ExperimentalSessionWorker { readonly terminated: Promise; readonly sessionId: string; readonly pid: number; readonly #child: ChildProcess; + readonly #control: WorkerControlServer; readonly #exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; readonly #shutdownTimeoutMs: number; #closePromise?: Promise; #closeRequested = false; - constructor(child: ChildProcess, sessionId: string, pid: number, shutdownTimeoutMs: number) { + constructor( + child: ChildProcess, + control: WorkerControlServer, + sessionId: string, + pid: number, + shutdownTimeoutMs: number, + ) { this.#child = child; + this.#control = control; this.sessionId = sessionId; this.pid = pid; this.#shutdownTimeoutMs = shutdownTimeoutMs; this.#exit = waitForExit(child); - this.terminated = this.#exit.then(({ code, signal }) => - this.#closeRequested + this.terminated = this.#exit.then(async ({ code, signal }) => { + await this.#control.close(); + return this.#closeRequested ? undefined - : new Error(`Session worker ${sessionId} exited unexpectedly (${signal ?? code ?? "unknown"})`), - ); + : new Error(`Session worker ${sessionId} exited unexpectedly (${signal ?? code ?? "unknown"})`); + }); } close(): Promise { @@ -96,69 +297,62 @@ class ChildSessionWorker implements ExperimentalSessionWorker { } async #close(): Promise { - if (this.#child.exitCode !== null || this.#child.signalCode !== null) return; - if (!this.#child.connected) { - this.#child.kill("SIGKILL"); - } else { - this.#child.send({ type: "shutdown" } satisfies SessionWorkerCommand, (error) => { - if (error) this.#child.kill("SIGKILL"); - }); - } - const timeout = setTimeout(() => this.#child.kill("SIGKILL"), this.#shutdownTimeoutMs); - timeout.unref(); - try { - await this.#exit; - } finally { - clearTimeout(timeout); + if (this.#child.exitCode === null && this.#child.signalCode === null) { + await this.#control.send({ type: "shutdown" }).catch(() => this.#child.kill("SIGKILL")); + const timeout = setTimeout(() => this.#child.kill("SIGKILL"), this.#shutdownTimeoutMs); + timeout.unref(); + try { + await this.#exit; + } finally { + clearTimeout(timeout); + } } + await this.#control.close(); } } -function waitForReady( - child: ChildProcess, - expectedSessionId: string, - startupTimeoutMs: number, -): Promise> { +function attachJsonLineReader(socket: Socket, onMessage: (message: unknown) => void): void { + let buffered = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffered += chunk; + while (true) { + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + try { + onMessage(JSON.parse(line)); + } catch { + socket.destroy(new Error("Session worker sent invalid JSON")); + return; + } + } + }); +} + +function listen(server: Server, address: string): Promise { return new Promise((resolve, reject) => { - let timeout: NodeJS.Timeout | undefined; - const cleanup = (): void => { - if (timeout) clearTimeout(timeout); - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const fail = (error: Error): void => { - cleanup(); + const onError = (error: Error): void => { + server.off("listening", onListening); reject(error); }; - const onMessage = (message: SessionWorkerEvent): void => { - if (message?.type === "failed") { - fail(new Error(`Session worker failed: ${message.message}`)); - return; - } - if (message?.type !== "ready") return; - if (message.sessionId !== expectedSessionId || message.pid !== child.pid) { - fail(new Error("Session worker reported an invalid identity")); - return; - } - cleanup(); - resolve(message); + const onListening = (): void => { + server.off("error", onError); + resolve(); }; - const onError = (error: Error): void => fail(error); - const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { - fail(new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`)); - }; - timeout = setTimeout( - () => fail(new Error(`Session worker startup timed out after ${startupTimeoutMs}ms`)), - startupTimeoutMs, - ); - timeout.unref(); - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); + server.once("error", onError); + server.once("listening", onListening); + server.listen(address); }); } +async function closeWorkerControl(server: Server, socket: Socket | undefined, address: string): Promise { + socket?.destroy(); + if (server.listening) await new Promise((resolve) => server.close(() => resolve())); + if (process.platform !== "win32") await unlink(address).catch(() => undefined); +} + function waitForExit(child: ChildProcess): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f5d8ed0c1..d2bf234fd 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -29,7 +29,7 @@ import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { experimentalCli } from "./cli/experimental/cli.ts"; import type { ClientCommand } from "./cli/experimental/commands/client.ts"; import type { ServerCommand } from "./cli/experimental/commands/server.ts"; -import { runExperimentalClient, startExperimentalServerGeneration } from "./cli/experimental/runtime.ts"; +import { runExperimentalClient, startExperimentalCoordinatedServer } from "./cli/experimental/runtime.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; @@ -590,7 +590,7 @@ async function waitForTermination(serverClosed: Promise): Promise { async function runExperimentalServerCommand(command: ServerCommand): Promise { if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server"); if (command.listen !== undefined) throw new Error("The local demo server uses its server-addressed Unix socket"); - const runtime = await startExperimentalServerGeneration({ sessionDir: command.sessionDir }); + const runtime = await startExperimentalCoordinatedServer({ sessionDir: command.sessionDir }); console.log(`Server: ${runtime.serverId}`); console.log(`Socket: ${runtime.socketPath}`); try { diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index edd6ac501..cb5614b34 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,11 +1,13 @@ import { chmod, lstat, mkdtemp, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { PiClient } from "@earendil-works/pi-client"; +import { createUnixTransportFactory } from "@earendil-works/pi-client/unix"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type ExperimentalServer, runExperimentalClient, + startExperimentalCoordinatedServer, startExperimentalServer, - startExperimentalServerGeneration, } from "../src/cli/experimental/runtime.ts"; import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts"; @@ -130,54 +132,101 @@ describe("experimental durable server composition", () => { for (const pid of pids) expect(processExists(pid)).toBe(false); }); - test("serializes concurrent launchers so only one generation remains active", async () => { + test("coordinated server replaces an exited worker on the next attach", async () => { + const directory = await mkdtemp(join("/tmp", "pew-")); + directories.add(directory); + const runtime = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + servers.add(runtime); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + const firstPid = runtime.workerPids.get("demo-1"); + expect(firstPid).toEqual(expect.any(Number)); + + process.kill(firstPid!, "SIGKILL"); + await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + const replacementPid = runtime.workerPids.get("demo-1"); + expect(replacementPid).toEqual(expect.any(Number)); + expect(replacementPid).not.toBe(firstPid); + }); + + test("normal coordinated shutdown stops detached workers", async () => { + const directory = await mkdtemp(join("/tmp", "pes-")); + directories.add(directory); + const runtime = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + servers.add(runtime); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + const pid = runtime.workerPids.get("demo-1"); + expect(pid).toEqual(expect.any(Number)); + + await runtime.close(); + expect(processExists(pid!)).toBe(false); + expect(runtime.workerPids.size).toBe(0); + await expect.poll(() => pathExists(runtime.socketPath)).toBe(false); + }); + + test("serializes concurrent launchers so only one server remains active", async () => { const directory = await mkdtemp(join("/tmp", "pel-")); directories.add(directory); - const generations = await Promise.all([ - startExperimentalServerGeneration({ directory, socketDirectory: directory }), - startExperimentalServerGeneration({ directory, socketDirectory: directory }), + const runtimes = await Promise.all([ + startExperimentalCoordinatedServer({ directory, socketDirectory: directory }), + startExperimentalCoordinatedServer({ directory, socketDirectory: directory }), ]); - for (const generation of generations) servers.add(generation); + for (const runtime of runtimes) servers.add(runtime); - expect(generations[0].serverId).toBe(generations[1].serverId); - const closed = await Promise.all( - generations.map((generation) => - Promise.race([ - generation.closed.then(() => true), - new Promise((resolve) => setImmediate(() => resolve(false))), - ]), - ), - ); - expect(closed.filter(Boolean)).toHaveLength(1); + expect(runtimes[0].serverId).toBe(runtimes[1].serverId); + await expect + .poll(async () => { + const closed = await Promise.all( + runtimes.map((runtime) => + Promise.race([ + runtime.closed.then(() => true), + new Promise((resolve) => setImmediate(() => resolve(false))), + ]), + ), + ); + return closed.filter(Boolean).length; + }) + .toBe(1); await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({ kind: "list", sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }], }); }); - test("starts a clean replacement and requires explicit reattachment", async () => { + test("shuts down the replaced server without waiting for its clients", async () => { + const directory = await mkdtemp(join("/tmp", "peg-")); + directories.add(directory); + const first = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + servers.add(first); + const client = await PiClient.connect({ + serverId: first.serverId, + transportFactory: createUnixTransportFactory({ path: first.socketPath }), + }); + await client.listSessions(); + + const replacement = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + servers.add(replacement); + await first.closed; + await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({ + kind: "list", + sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }], + }); + await client.dispose(); + }); + + test("discovers workers after replacing the server", async () => { const firstDirectory = await mkdtemp(join("/tmp", "per-")); - const otherDirectory = await mkdtemp(join("/tmp", "per-")); directories.add(firstDirectory); - directories.add(otherDirectory); - const first = await startExperimentalServerGeneration({ + const first = await startExperimentalCoordinatedServer({ directory: firstDirectory, socketDirectory: firstDirectory, }); - const other = await startExperimentalServerGeneration({ - directory: otherDirectory, - socketDirectory: otherDirectory, - }); servers.add(first); - servers.add(other); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory }); - await runExperimentalClient({ command: "client", sessionId: "demo-2" }, { directory: otherDirectory }); const firstWorkerPid = first.workerPids.get("demo-1"); - const otherWorkerPid = other.workerPids.get("demo-2"); expect(firstWorkerPid).toEqual(expect.any(Number)); - expect(otherWorkerPid).toEqual(expect.any(Number)); - const replacement = await startExperimentalServerGeneration({ + const replacement = await startExperimentalCoordinatedServer({ directory: firstDirectory, socketDirectory: firstDirectory, }); @@ -185,11 +234,9 @@ describe("experimental durable server composition", () => { await first.closed; expect(replacement.serverId).toBe(first.serverId); - expect(replacement.workerPids.size).toBe(0); - expect(first.workerPids.size).toBe(0); - expect(processExists(firstWorkerPid!)).toBe(false); - expect(other.workerPids.get("demo-2")).toBe(otherWorkerPid); - expect(processExists(otherWorkerPid!)).toBe(true); + expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid); + await expect.poll(() => first.workerPids.size).toBe(0); + expect(processExists(firstWorkerPid!)).toBe(true); await expect(runExperimentalClient({ command: "client" }, { directory: firstDirectory })).resolves.toMatchObject({ kind: "list", @@ -199,8 +246,36 @@ describe("experimental durable server composition", () => { ], }); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory }); - expect(replacement.workerPids.get("demo-1")).toEqual(expect.any(Number)); - expect(replacement.workerPids.get("demo-1")).not.toBe(firstWorkerPid); + expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid); + await runExperimentalClient({ command: "client", sessionId: "demo-2" }, { directory: firstDirectory }); + expect(replacement.workerPids.get("demo-2")).toEqual(expect.any(Number)); + expect(replacement.workerPids.get("demo-2")).not.toBe(firstWorkerPid); + }); + + test("restores tracked sessions that are outside the replacement catalog", async () => { + const directory = await mkdtemp(join("/tmp", "pet-")); + const emptySessionDir = await mkdtemp(join("/tmp", "pet-sessions-")); + directories.add(directory); + directories.add(emptySessionDir); + const first = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + servers.add(first); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + const workerPid = first.workerPids.get("demo-1"); + + const replacement = await startExperimentalCoordinatedServer({ + directory, + socketDirectory: directory, + sessionDir: emptySessionDir, + }); + servers.add(replacement); + await first.closed; + + await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({ + kind: "list", + sessions: [{ serverId: first.serverId, sessionId: "demo-1" }], + }); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + expect(replacement.workerPids.get("demo-1")).toBe(workerPid); }); test("reports missing and ambiguous session selections", async () => { @@ -232,6 +307,16 @@ describe("experimental durable server composition", () => { }); }); +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + function processExists(pid: number): boolean { try { process.kill(pid, 0); diff --git a/packages/coding-agent/test/experimental-server-replacement.test.ts b/packages/coding-agent/test/experimental-server-replacement.test.ts index 32e918fe7..d482ec574 100644 --- a/packages/coding-agent/test/experimental-server-replacement.test.ts +++ b/packages/coding-agent/test/experimental-server-replacement.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; import { runExperimentalClient } from "../src/cli/experimental/runtime.ts"; +import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts"; const cliPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); const processes = new Set(); @@ -17,10 +18,10 @@ interface RunningCli { readonly errors: () => string; } -async function startServer(home: string): Promise { +async function startServer(home: string, agentDir: string): Promise { const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], { cwd: fileURLToPath(new URL("../../..", import.meta.url)), - env: { ...process.env, HOME: home, PI_EXPERIMENTAL: "1" }, + env: { ...process.env, HOME: home, PI_CODING_AGENT_DIR: agentDir, PI_EXPERIMENTAL: "1" }, stdio: ["ignore", "pipe", "pipe"], }); processes.add(child); @@ -91,12 +92,15 @@ afterEach(async () => { }); describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => { - test("a second CLI generation starts clean and accepts explicit reattachment", async () => { + test("a second CLI server takes over the stable coordinator socket", async () => { const root = await mkdtemp(join(tmpdir(), "pcs-")); directories.add(root); const home = join(root, "long-home-segment".repeat(8)); await mkdir(home); - const first = await startServer(home); + const agentDir = join(root, "agent"); + await configureExperimentalWorkerModel(agentDir); + await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]); + const first = await startServer(home, agentDir); const firstIdentity = await waitForOutput( first, /Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/, @@ -110,7 +114,7 @@ describe.skipIf(process.platform === "win32")("experimental CLI server replaceme connect: { transport: "unix", path: firstSocket[1]! }, }); - const replacement = await startServer(home); + const replacement = await startServer(home, agentDir); const replacementIdentity = await waitForOutput( replacement, /Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/, diff --git a/packages/coding-agent/test/fixtures/session-worker-fixture.ts b/packages/coding-agent/test/fixtures/session-worker-fixture.ts index 710b86cef..0503ba8de 100644 --- a/packages/coding-agent/test/fixtures/session-worker-fixture.ts +++ b/packages/coding-agent/test/fixtures/session-worker-fixture.ts @@ -1,6 +1,12 @@ +import { createConnection } from "node:net"; + // Test-only child executable. Keep this independent from the real worker so // lifecycle failure paths remain deterministic as production integration evolves. -type Command = { type: "shutdown" }; +const address = process.env.PI_SESSION_WORKER_CONTROL_ADDRESS; +const token = process.env.PI_SESSION_WORKER_CONTROL_TOKEN; +const encodedSessionKey = process.env.PI_SESSION_WORKER_SESSION_KEY_BASE64; +if (!address || !token || !encodedSessionKey) throw new Error("Fixture requires worker control configuration"); +const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString(); const parsedMetadata: unknown = JSON.parse(process.argv[3] ?? "null"); const sessionId = @@ -8,24 +14,38 @@ const sessionId = ? parsedMetadata.id : undefined; const mode = typeof sessionId === "string" ? sessionId.replaceAll("\0", "") : undefined; +const socket = createConnection(address); +await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); +}); +const send = (message: unknown): void => { + socket.write(`${JSON.stringify(message)}\n`); +}; +send({ type: "register_worker", protocol: 1, token, sessionKey, sessionId, pid: process.pid }); if (mode === "ready") { - process.send?.({ type: "ready", sessionId, pid: process.pid }); - process.on("message", (message: Command) => { - if (message?.type === "shutdown") process.exit(0); + send({ type: "worker_ready", sessionId, pid: process.pid }); + let buffered = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffered += chunk; + if (buffered.includes("\n") && JSON.parse(buffered.slice(0, buffered.indexOf("\n"))).type === "shutdown") { + process.exit(0); + } }); } else if (mode === "fail") { - process.send?.({ type: "failed", message: "fixture startup failed" }); + send({ type: "worker_failed", message: "fixture startup failed" }); } else if (mode === "exit") { process.exit(2); } else if (mode === "startup-hang") { - process.on("message", () => {}); + socket.resume(); } else if (mode === "hang") { - process.send?.({ type: "ready", sessionId, pid: process.pid }); - process.on("message", () => {}); + send({ type: "worker_ready", sessionId, pid: process.pid }); + socket.resume(); } else { - process.send?.({ - type: "ready", + send({ + type: "worker_ready", sessionId: sessionId === undefined ? "missing" : `${sessionId}-different`, pid: process.pid, }); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 20b0a9df9..38fbfad4d 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -8,7 +8,6 @@ "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"], "@earendil-works/pi-client": ["../client/dist/index.d.ts"], - "@earendil-works/pi-client/control": ["../client/dist/control.d.ts"], "@earendil-works/pi-client/unix": ["../client/dist/unix.d.ts"], "@earendil-works/pi-client/*": ["../client/dist/*.d.ts"], "@earendil-works/pi-server": ["../server/dist/index.d.ts"], diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 057422e69..591fef1fb 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -6,14 +6,11 @@ Protocol version `1` currently contains the first control-plane slice: - a version handshake that identifies the logical `serverId`; - a service RPC manifest with `list()` and `attach(sessionId)`; -- a separate launcher-only server-control manifest with `drain()`; - correlated responses and bounded protocol errors. The manifest generates typed client methods and validated server dispatch. The wire uses generic `{ serverId, method, args }` calls rather than a hand-written command union. `list()` returns the durable `SessionMetadata` values from `SessionRepo.list()`. `attach()` returns only the attached `sessionId`; the real `Session` and `AgentHarness` remain hosted by the server. -`drain()` tells one server generation to close its hosted Harnesses, acknowledges successful cleanup, and begins shutdown. It does not transfer hosted-session state. The server does not know whether another generation will replace it; replacement and explicit client reattachment belong to the launcher and clients. - -Separating `ServerControlRpc` from `ServiceRpc` is an API boundary, not an authorization boundary. Transports exposing server control must authenticate the caller as an administrator. The experimental local launcher uses a mode-`0600` Unix socket and trusts same-user peers. +Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol. Each wire frame consists of a four-byte unsigned big-endian payload length followed by one definite-length CBOR item. `encodeClientMessage()` and `encodeServerMessage()` validate and encode complete frames. `ClientMessageDecoder` and `ServerMessageDecoder` accept arbitrary stream fragmentation and coalescing. diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 85e5d3eae..2e06962bf 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -63,31 +63,10 @@ export type ServiceRpcResultUnion = RpcResultUnion; export const ServiceRpcCallSchema = Type.Unsafe(createRpcCallSchema(ServiceRpc)); export const ServiceRpcResultSchema = Type.Unsafe(createRpcResultSchema(ServiceRpc)); -/** Administrative lifecycle operations reserved for the server launcher. */ -export const ServerControlRpc = defineRpc({ - drain: { - args: Type.Tuple([]), - result: StrictObject({}), - }, -}); -export type ServerControlRpcManifest = typeof ServerControlRpc; -export type ServerControlRpcMethod = RpcMethodName; -export type ServerControlRpcArgs = RpcArgs; -export type ServerControlRpcResult = RpcResult< - ServerControlRpcManifest, - TMethod ->; -export type ServerControlRpcCall = RpcCall; -export type ServerControlRpcResultUnion = RpcResultUnion; -export const ServerControlRpcCallSchema = Type.Unsafe(createRpcCallSchema(ServerControlRpc)); -export const ServerControlRpcResultSchema = Type.Unsafe( - createRpcResultSchema(ServerControlRpc), -); - -export type ProtocolRpcCall = ServiceRpcCall | ServerControlRpcCall; -export type ProtocolRpcResult = ServiceRpcResultUnion | ServerControlRpcResultUnion; -const ProtocolRpcCallSchema = Type.Union([ServiceRpcCallSchema, ServerControlRpcCallSchema]); -const ProtocolRpcResultSchema = Type.Union([ServiceRpcResultSchema, ServerControlRpcResultSchema]); +export type ProtocolRpcCall = ServiceRpcCall; +export type ProtocolRpcResult = ServiceRpcResultUnion; +const ProtocolRpcCallSchema = ServiceRpcCallSchema; +const ProtocolRpcResultSchema = ServiceRpcResultSchema; export const ProtocolErrorCodeSchema = Type.Union([ Type.Literal("version"), diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 918afdaef..89c063084 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -17,7 +17,6 @@ import { ProtocolValidationError, parseClientMessage, parseServerMessage, - ServerControlRpc, type ServerHello, type ServerMessage, ServerMessageDecoder, @@ -79,22 +78,6 @@ describe("RPC manifest", () => { await expect(dispatch({ method: "list", args: [] }, undefined)).rejects.toThrow(/Invalid result.*list/); }); - test("keeps launcher control methods separate from session operations", async () => { - const calls: unknown[] = []; - const control = createRpcClient(ServerControlRpc, async (call) => { - calls.push(call); - return {}; - }); - const dispatch = createRpcDispatcher(ServerControlRpc, { - drain: () => ({}), - }); - - await expect(control.drain()).resolves.toEqual({}); - await expect(dispatch({ method: "drain", args: [] }, undefined)).resolves.toEqual({}); - expect(calls).toEqual([{ method: "drain", args: [] }]); - expect("drain" in createRpcClient(ServiceRpc, async () => [])).toBe(false); - }); - test("rejects empty manifests instead of creating unusable RPC clients", () => { expect(() => defineRpc({})).toThrow(/at least one method/); }); @@ -138,7 +121,7 @@ describe("protocol validation", () => { ).toThrow(ProtocolValidationError); }); - test("validates service and launcher-control RPC calls with logical targets", () => { + test("validates service RPC calls with logical targets", () => { const list: ClientMessage = { type: "request", id: "request-1", @@ -151,15 +134,8 @@ describe("protocol validation", () => { serverId: "00000000-0000-4000-8000-000000000001", call: { method: "attach", args: ["session-1"] }, }; - const drain: ClientMessage = { - type: "request", - id: "request-3", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "drain", args: [] }, - }; expect(parseClientMessage(list)).toEqual(list); expect(parseClientMessage(attach)).toEqual(attach); - expect(parseClientMessage(drain)).toEqual(drain); expect(() => parseClientMessage({ ...attach, call: { method: "attach", args: [] } })).toThrow( ProtocolValidationError, ); @@ -191,17 +167,6 @@ describe("protocol validation", () => { expect(parseServerMessage(message)).toEqual(message); }); - test("validates drain acknowledgements", () => { - const message: ServerMessage = { - type: "response", - id: "request-1", - ok: true, - result: {}, - }; - expect(parseServerMessage(message)).toEqual(message); - expect(() => parseServerMessage({ ...message, result: { sessionIds: [] } })).toThrow(ProtocolValidationError); - }); - test.each([ [ "empty request id", diff --git a/packages/server/README.md b/packages/server/README.md index becbaf2f8..b4dae2008 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -7,8 +7,6 @@ The current slice supports two client operations: - `list` calls `SessionRepo.list()` without opening sessions. - `attach` finds the requested metadata, passes it to the host, and retains the returned Harness handle in the server. -The separate launcher-control operation `drain` closes hosted Harnesses, acknowledges successful cleanup, and closes that server generation. It does not transfer hosted-session state, and the server does not decide whether a replacement should start. Clients explicitly reconnect and reattach after replacement. - Concurrent attachments to one session reuse one hosted Harness. Attachment is a one-shot acquisition request, so losing the client connection does not close the Harness. Server shutdown closes every hosted Harness, releasing its Session writer ownership. ```ts @@ -60,4 +58,4 @@ Applications supply a session catalog and a Harness factory. The server only cal `PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`. -`ServerControlRpc` is administrative but is not itself an authorization mechanism. A transport exposing it must trust every connected peer as an administrator or authenticate control access separately. The experimental local launcher enforces mode `0600` on its Unix socket and therefore trusts same-user peers. +Server and worker lifecycle is managed by the replaceable application server rather than the public Pi protocol. The experimental coordinator only supplies stable routing between processes. diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 539f2ed5a..155072386 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -13,7 +13,6 @@ import { ProtocolValidationError, type RequestEnvelope, type ResponseEnvelope, - type ServerControlRpcResult, type ServerHello, type ServerHelloError, type ServerMessage, @@ -24,7 +23,7 @@ import { type ConnectionState, isTerminalConnection, } from "./connection.ts"; -import { INTERNAL_SERVER_ERROR_MESSAGE, PiServerError, ServerDrainingError, WrongServerError } from "./errors.ts"; +import { INTERNAL_SERVER_ERROR_MESSAGE, PiServerError, WrongServerError } from "./errors.ts"; import { HostedHarnessManager } from "./hosted-harness-manager.ts"; import type { PiServerListener } from "./listener.ts"; import type { PiServerHost, PiServerOptions } from "./types.ts"; @@ -254,19 +253,9 @@ export class PiServer { } private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise { - const draining = envelope.call.method === "drain"; - let ownsDrain = false; try { if (envelope.serverId !== this.serverId) throw new WrongServerError(); - let result: ProtocolRpcResult; - if (draining) { - if (this.closing) throw new ServerDrainingError(); - this.closing = true; - ownsDrain = true; - result = await this.drain(); - } else { - result = await this.sessions.executeCall(envelope.call); - } + const result: ProtocolRpcResult = await this.sessions.executeCall(envelope.call); await this.sendMessage(state, { type: "response", id: envelope.id, @@ -280,20 +269,9 @@ export class PiServer { ok: false, error: this.toProtocolError(error), } satisfies ResponseEnvelope); - } finally { - if (ownsDrain) this.scheduleClose(); } } - private async drain(): Promise> { - await this.sessions.close(); - return {}; - } - - private scheduleClose(): void { - void this.close().catch((error: unknown) => this.reportError(error)); - } - private transportClosed(connection: ConnectionState): void { if (!connection.disconnected && connection.stage !== "closing") { try { diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index d8ec9d389..6ec24834b 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -245,108 +245,6 @@ describe("list and attach protocol", () => { }); }); -describe("server draining", () => { - test("drains hosted sessions, acknowledges, and closes", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - await client.request("00000000-0000-4000-8000-000000000001", { - method: "attach", - args: ["session-1"], - }); - const harness = host.latestHarness("session-1"); - - await expect( - client.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }), - ).resolves.toMatchObject({ - ok: true, - result: {}, - }); - await client.waitForClose(); - await server.closed; - - expect(harness.closeCount).toBe(1); - }); - - test("rejects a drain addressed to another server", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - await client.request("00000000-0000-4000-8000-000000000001", { - method: "attach", - args: ["session-1"], - }); - - await expect( - client.request("00000000-0000-4000-8000-000000000002", { method: "drain", args: [] }), - ).resolves.toMatchObject({ - ok: false, - error: { code: "wrong_server" }, - }); - - expect(host.latestHarness("session-1").closeCount).toBe(0); - }); - - test("only the drain owner schedules shutdown", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const owner = connect(server); - const concurrent = connect(server); - await Promise.all([owner.hello(), concurrent.hello()]); - await owner.request("00000000-0000-4000-8000-000000000001", { - method: "attach", - args: ["session-1"], - }); - const gate = host.latestHarness("session-1").gateNextClose(); - - const draining = owner.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }); - await gate.entered.promise; - await expect( - concurrent.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }), - ).resolves.toMatchObject({ - ok: false, - error: { code: "server_draining" }, - }); - expect(owner.closed).toBe(false); - - gate.release.resolve(undefined); - await expect(draining).resolves.toMatchObject({ ok: true, result: {} }); - await server.closed; - }); - - test("rejects drain acknowledgement when any hosted Harness fails to close", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - await host.seed("session-2"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }); - await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-2"] }); - const failedHarness = host.latestHarness("session-1"); - const closedHarness = host.latestHarness("session-2"); - failedHarness.failClose = new Error("close failed"); - - await expect( - client.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }), - ).resolves.toMatchObject({ - ok: false, - error: { code: "internal_error" }, - }); - await expect(server.closed).rejects.toThrow(/Failed to close hosted Harnesses/); - expect(failedHarness.closeCount).toBe(1); - expect(closedHarness.closeCount).toBe(1); - - servers.delete(server); - await failedHarness.close(); - }); -}); - describe("hosted Harness acquisition failures", () => { test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => { const host = new TestServerHost(); diff --git a/tsconfig.json b/tsconfig.json index 1ad0330cd..cc2f9a85f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,6 @@ "@earendil-works/pi-protocol": ["./packages/protocol/src/index.ts"], "@earendil-works/pi-protocol/*": ["./packages/protocol/src/*"], "@earendil-works/pi-client": ["./packages/client/src/index.ts"], - "@earendil-works/pi-client/control": ["./packages/client/src/control.ts"], "@earendil-works/pi-client/unix": ["./packages/client/src/unix.ts"], "@earendil-works/pi-client/*": ["./packages/client/src/*"], "@earendil-works/pi-server": ["./packages/server/src/index.ts"], diff --git a/vitest.base.ts b/vitest.base.ts index 080b2b2a0..7592113ea 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -12,7 +12,6 @@ export const workspaceSourcePaths = { agentNode: fileURLToPath(new URL("./packages/agent/src/node.ts", import.meta.url)), protocolIndex: fileURLToPath(new URL("./packages/protocol/src/index.ts", import.meta.url)), clientIndex: fileURLToPath(new URL("./packages/client/src/index.ts", import.meta.url)), - clientControl: fileURLToPath(new URL("./packages/client/src/control.ts", import.meta.url)), clientUnix: fileURLToPath(new URL("./packages/client/src/unix.ts", import.meta.url)), serverIndex: fileURLToPath(new URL("./packages/server/src/index.ts", import.meta.url)), serverUnix: fileURLToPath(new URL("./packages/server/src/transports/unix/index.ts", import.meta.url)), @@ -36,7 +35,6 @@ export default defineConfig({ { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: workspaceSourcePaths.agentNode }, { find: /^@earendil-works\/pi-protocol$/, replacement: workspaceSourcePaths.protocolIndex }, { find: /^@earendil-works\/pi-client$/, replacement: workspaceSourcePaths.clientIndex }, - { find: /^@earendil-works\/pi-client\/control$/, replacement: workspaceSourcePaths.clientControl }, { find: /^@earendil-works\/pi-client\/unix$/, replacement: workspaceSourcePaths.clientUnix }, { find: /^@earendil-works\/pi-server$/, replacement: workspaceSourcePaths.serverIndex }, { find: /^@earendil-works\/pi-server\/unix$/, replacement: workspaceSourcePaths.serverUnix }, From d96144c6a3f21584d04e482f577eb48a9c2bfa67 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 15 Aug 2026 12:06:59 +0200 Subject: [PATCH 6/9] feat(coding-agent): make server directory configurable Place the server profile and all Unix sockets under PI_SERVER_DIR, defaulting to ~/.pi/server. Keep test sockets isolated through the same override. --- packages/coding-agent/README.md | 1 + .../docs/environment-variables.md | 1 + packages/coding-agent/src/cli/args.ts | 1 + .../cli/experimental/coordinator-client.ts | 9 --- .../src/cli/experimental/runtime.ts | 56 ++++++------------- .../src/cli/experimental/server-directory.ts | 21 +++++++ .../src/cli/experimental/session-worker.ts | 13 ++++- .../test/experimental-remote-runtime.test.ts | 46 +++++++++------ .../experimental-server-replacement.test.ts | 24 +++++--- .../test/experimental-session-worker.test.ts | 8 ++- 10 files changed, 103 insertions(+), 77 deletions(-) create mode 100644 packages/coding-agent/src/cli/experimental/server-directory.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e6e43c2eb..1b28ff65e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -672,6 +672,7 @@ pi --thinking high "Solve this complex problem" | `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) | | `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) | | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | +| `PI_SERVER_DIR` | Override experimental server profile and socket directory (default: `~/.pi/server`) | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 29c324f63..9d79d2a25 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -81,6 +81,7 @@ These variables are read by Pi itself: | `PI_CODING_AGENT_DIR` | Override the config directory; default is `~/.pi/agent` | | `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` | | `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths | +| `PI_SERVER_DIR` | Override the experimental server profile and socket directory; default is `~/.pi/server` | | `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3c0e01b3c..09a3549d8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -412,6 +412,7 @@ ${chalk.bold("Environment Variables:")} ${ENV_AGENT_DIR.padEnd(32)} - Config directory (default: ~/${CONFIG_DIR_NAME}/agent) ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) + PI_SERVER_DIR - Experimental server profile and socket directory (default: ~/.pi/server) PI_OFFLINE - Disable startup network operations when set to 1/true/yes PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) diff --git a/packages/coding-agent/src/cli/experimental/coordinator-client.ts b/packages/coding-agent/src/cli/experimental/coordinator-client.ts index 177048a1f..bdce86193 100644 --- a/packages/coding-agent/src/cli/experimental/coordinator-client.ts +++ b/packages/coding-agent/src/cli/experimental/coordinator-client.ts @@ -1,7 +1,6 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createConnection, type Socket } from "node:net"; -import { join } from "node:path"; import { fileURLToPath } from "node:url"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; @@ -176,14 +175,6 @@ export class CoordinatorServer { } } -export function getExperimentalCoordinatorControlPath(serverId: string, socketDirectory: string): string { - return join(socketDirectory, `.c-${serverId.slice(0, 12)}.sock`); -} - -export function getExperimentalServerPath(socketDirectory: string): string { - return join(socketDirectory, `.s-${randomUUID().slice(0, 12)}.sock`); -} - export interface CoordinatorStartupLease { close(): void; } diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 8e2c0a43a..83fa5ed0a 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { chmod, lstat, mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; @@ -17,14 +15,11 @@ import { CoordinatorServer, type CoordinatorStartupLease, ensureExperimentalCoordinator, - getExperimentalCoordinatorControlPath, - getExperimentalServerPath, } from "./coordinator-client.ts"; +import { ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts"; import { acquireExperimentalServerProfile } from "./server-profile.ts"; import { startExperimentalSessionWorker } from "./session-worker.ts"; -const EXPERIMENTAL_SOCKET_ROOT = "/tmp"; - export interface ExperimentalServer { readonly serverId: string; readonly sessionDir: string; @@ -43,7 +38,7 @@ export type ExperimentalClientResult = | { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string }; export interface StartExperimentalServerOptions { - /** Directory for server-addressed Unix sockets. Defaults to a short, private per-user runtime directory. */ + /** Directory for server sockets. Defaults to PI_SERVER_DIR or ~/.pi/server. */ readonly directory?: string; readonly path?: string; readonly serverId?: string; @@ -52,16 +47,14 @@ export interface StartExperimentalServerOptions { } export interface StartExperimentalCoordinatedServerOptions { - /** Persistent profile directory. Defaults to ~/.pi/server. */ + /** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */ readonly directory?: string; - /** Physical socket directory. Defaults to a short, private per-user runtime directory. */ - readonly socketDirectory?: string; /** Durable session directory. Defaults to the experimental directory under the configured agent directory. */ readonly sessionDir?: string; } export interface RunExperimentalClientOptions { - /** Directory searched when --connect is omitted. Defaults to the experimental per-user runtime directory. */ + /** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */ readonly directory?: string; } @@ -86,7 +79,10 @@ export async function startExperimentalServer( trackedSessions: [], createHarness: async (metadata) => { const sessionId = metadata.id; - const worker = await startExperimentalSessionWorker(metadata, { sessionDir }); + const worker = await startExperimentalSessionWorker(metadata, { + sessionDir, + controlDirectory: options.directory, + }); workerPids.set(sessionId, worker.pid); return { terminated: worker.terminated.then((error) => { @@ -126,9 +122,9 @@ async function startExperimentalServerBackend( }; let socketPath = options.path; if (socketPath === undefined) { - const socketDirectory = options.directory ?? getExperimentalSocketDirectory(); - await ensurePrivateSocketDirectory(socketDirectory); - socketPath = getUnixSocketPath(serverId, socketDirectory); + const serverDirectory = resolveExperimentalServerDirectory(options.directory); + await ensurePrivateServerDirectory(serverDirectory); + socketPath = getUnixSocketPath(serverId, serverDirectory); } const closeCatalog = async (): Promise => { const cleanup = await Promise.allSettled([repo.close(), executionEnv.cleanup()]); @@ -181,8 +177,7 @@ async function startExperimentalServerBackend( export async function startExperimentalCoordinatedServer( options: StartExperimentalCoordinatedServerOptions = {}, ): Promise { - const directory = options.directory ?? join(homedir(), ".pi", "server"); - const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory(); + const directory = resolveExperimentalServerDirectory(options.directory); const { serverId, release } = await acquireExperimentalServerProfile(directory); let backend: ExperimentalServer | undefined; let coordinator: CoordinatorServer | undefined; @@ -190,10 +185,10 @@ export async function startExperimentalCoordinatedServer( let workers: CoordinatedSessionWorkers | undefined; let released = false; try { - await ensurePrivateSocketDirectory(socketDirectory); - const socketPath = getUnixSocketPath(serverId, socketDirectory); - const controlPath = getExperimentalCoordinatorControlPath(serverId, socketDirectory); - const serverPath = getExperimentalServerPath(socketDirectory); + await ensurePrivateServerDirectory(directory); + const socketPath = getUnixSocketPath(serverId, directory); + const controlPath = join(directory, "control.sock"); + const serverPath = join(directory, `.server-${randomUUID().slice(0, 12)}.sock`); startupLease = await ensureExperimentalCoordinator(socketPath, controlPath); coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath }); const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); @@ -269,7 +264,7 @@ export async function runExperimentalClient( if (command.auth !== undefined) throw new Error("Authentication is not supported by the experimental local server"); const routes = command.connect ? [routeFromExplicitPath(command.connect.path)] - : await discoverUnixServers({ directory: options.directory ?? getExperimentalSocketDirectory() }); + : await discoverUnixServers({ directory: resolveExperimentalServerDirectory(options.directory) }); const discovered: { route: UnixServerRoute; sessionIds: string[] }[] = []; for (const route of routes) { @@ -324,20 +319,3 @@ function routeFromExplicitPath(path: string): UnixServerRoute { } return { serverId, path }; } - -function getExperimentalSocketDirectory(): string { - if (process.platform === "win32" || typeof process.getuid !== "function") { - throw new Error("Experimental Unix server transport requires a POSIX user ID"); - } - return join(EXPERIMENTAL_SOCKET_ROOT, `pi-server-${process.getuid()}`); -} - -async function ensurePrivateSocketDirectory(directory: string): Promise { - if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID"); - await mkdir(directory, { recursive: true, mode: 0o700 }); - const stats = await lstat(directory); - if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`); - if (stats.uid !== process.getuid()) - throw new Error(`Unix socket directory is not owned by the current user: ${directory}`); - await chmod(directory, 0o700); -} diff --git a/packages/coding-agent/src/cli/experimental/server-directory.ts b/packages/coding-agent/src/cli/experimental/server-directory.ts new file mode 100644 index 000000000..f8d31ec85 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/server-directory.ts @@ -0,0 +1,21 @@ +import { chmod, lstat, mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { resolvePath } from "../../utils/paths.ts"; + +export const ENV_SERVER_DIR = "PI_SERVER_DIR"; + +export function resolveExperimentalServerDirectory(directory?: string): string { + return resolvePath(directory ?? process.env[ENV_SERVER_DIR] ?? join(homedir(), ".pi", "server")); +} + +export async function ensurePrivateServerDirectory(directory: string): Promise { + if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const stats = await lstat(directory); + if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`); + if (stats.uid !== process.getuid()) { + throw new Error(`Unix socket directory is not owned by the current user: ${directory}`); + } + await chmod(directory, 0o700); +} diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index f6e6cfe25..e01da838d 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -2,12 +2,12 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, unlink } from "node:fs/promises"; import { createServer, type Server, type Socket } from "node:net"; -import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; +import { ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts"; import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts"; const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; @@ -47,6 +47,7 @@ export interface SessionWorkerLaunchSpec { export interface StartExperimentalSessionWorkerOptions { readonly sessionDir: string; + readonly controlDirectory?: string; readonly startupTimeoutMs?: number; readonly shutdownTimeoutMs?: number; readonly workerUrl?: URL; @@ -90,8 +91,14 @@ export async function startExperimentalSessionWorker( const launch = createExperimentalSessionWorkerLaunchSpec(metadata, options); const token = randomUUID(); const controlId = randomUUID(); - const controlAddress = - process.platform === "win32" ? `\\\\.\\pipe\\pi-worker-${controlId}` : join(tmpdir(), `pi-w-${controlId}.sock`); + let controlAddress: string; + if (process.platform === "win32") { + controlAddress = `\\\\.\\pipe\\pi-worker-${controlId}`; + } else { + const controlDirectory = resolveExperimentalServerDirectory(options.controlDirectory); + await ensurePrivateServerDirectory(controlDirectory); + controlAddress = join(controlDirectory, `.worker-${controlId}.sock`); + } const control = await createWorkerControlServer(controlAddress, token, metadata.id); let child: ChildProcess; try { diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index cb5614b34..4b9267938 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -64,6 +64,27 @@ describe("experimental durable server composition", () => { expect(runtime.workerPids.size).toBe(0); }); + test("uses PI_SERVER_DIR for sockets and discovery", async () => { + const directory = await mkdtemp(join("/tmp", "pi-server-dir-")); + directories.add(directory); + vi.stubEnv("PI_SERVER_DIR", directory); + const runtime = await startExperimentalCoordinatedServer(); + servers.add(runtime); + + expect(runtime.socketPath).toBe(join(directory, `${runtime.serverId}.sock`)); + expect((await lstat(directory)).mode & 0o777).toBe(0o700); + const publicSocket = await lstat(runtime.socketPath); + const controlSocket = await lstat(join(directory, "control.sock")); + expect(publicSocket.isSocket()).toBe(true); + expect(publicSocket.mode & 0o777).toBe(0o600); + expect(controlSocket.isSocket()).toBe(true); + expect(controlSocket.mode & 0o777).toBe(0o600); + await expect(runExperimentalClient({ command: "client" })).resolves.toMatchObject({ + kind: "list", + sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }], + }); + }); + test("discovers and lists seeded sessions without hosting either session", async () => { const { directory, runtime } = await makeServer(); @@ -135,7 +156,7 @@ describe("experimental durable server composition", () => { test("coordinated server replaces an exited worker on the next attach", async () => { const directory = await mkdtemp(join("/tmp", "pew-")); directories.add(directory); - const runtime = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + const runtime = await startExperimentalCoordinatedServer({ directory }); servers.add(runtime); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); const firstPid = runtime.workerPids.get("demo-1"); @@ -152,7 +173,7 @@ describe("experimental durable server composition", () => { test("normal coordinated shutdown stops detached workers", async () => { const directory = await mkdtemp(join("/tmp", "pes-")); directories.add(directory); - const runtime = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + const runtime = await startExperimentalCoordinatedServer({ directory }); servers.add(runtime); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); const pid = runtime.workerPids.get("demo-1"); @@ -168,8 +189,8 @@ describe("experimental durable server composition", () => { const directory = await mkdtemp(join("/tmp", "pel-")); directories.add(directory); const runtimes = await Promise.all([ - startExperimentalCoordinatedServer({ directory, socketDirectory: directory }), - startExperimentalCoordinatedServer({ directory, socketDirectory: directory }), + startExperimentalCoordinatedServer({ directory }), + startExperimentalCoordinatedServer({ directory }), ]); for (const runtime of runtimes) servers.add(runtime); @@ -196,7 +217,7 @@ describe("experimental durable server composition", () => { test("shuts down the replaced server without waiting for its clients", async () => { const directory = await mkdtemp(join("/tmp", "peg-")); directories.add(directory); - const first = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + const first = await startExperimentalCoordinatedServer({ directory }); servers.add(first); const client = await PiClient.connect({ serverId: first.serverId, @@ -204,7 +225,7 @@ describe("experimental durable server composition", () => { }); await client.listSessions(); - const replacement = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + const replacement = await startExperimentalCoordinatedServer({ directory }); servers.add(replacement); await first.closed; await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({ @@ -217,19 +238,13 @@ describe("experimental durable server composition", () => { test("discovers workers after replacing the server", async () => { const firstDirectory = await mkdtemp(join("/tmp", "per-")); directories.add(firstDirectory); - const first = await startExperimentalCoordinatedServer({ - directory: firstDirectory, - socketDirectory: firstDirectory, - }); + const first = await startExperimentalCoordinatedServer({ directory: firstDirectory }); servers.add(first); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory }); const firstWorkerPid = first.workerPids.get("demo-1"); expect(firstWorkerPid).toEqual(expect.any(Number)); - const replacement = await startExperimentalCoordinatedServer({ - directory: firstDirectory, - socketDirectory: firstDirectory, - }); + const replacement = await startExperimentalCoordinatedServer({ directory: firstDirectory }); servers.add(replacement); await first.closed; @@ -257,14 +272,13 @@ describe("experimental durable server composition", () => { const emptySessionDir = await mkdtemp(join("/tmp", "pet-sessions-")); directories.add(directory); directories.add(emptySessionDir); - const first = await startExperimentalCoordinatedServer({ directory, socketDirectory: directory }); + const first = await startExperimentalCoordinatedServer({ directory }); servers.add(first); await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); const workerPid = first.workerPids.get("demo-1"); const replacement = await startExperimentalCoordinatedServer({ directory, - socketDirectory: directory, sessionDir: emptySessionDir, }); servers.add(replacement); diff --git a/packages/coding-agent/test/experimental-server-replacement.test.ts b/packages/coding-agent/test/experimental-server-replacement.test.ts index d482ec574..c6cc08275 100644 --- a/packages/coding-agent/test/experimental-server-replacement.test.ts +++ b/packages/coding-agent/test/experimental-server-replacement.test.ts @@ -1,7 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { lstat, mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; @@ -18,10 +17,16 @@ interface RunningCli { readonly errors: () => string; } -async function startServer(home: string, agentDir: string): Promise { +async function startServer(home: string, agentDir: string, serverDir: string): Promise { const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], { cwd: fileURLToPath(new URL("../../..", import.meta.url)), - env: { ...process.env, HOME: home, PI_CODING_AGENT_DIR: agentDir, PI_EXPERIMENTAL: "1" }, + env: { + ...process.env, + HOME: home, + PI_CODING_AGENT_DIR: agentDir, + PI_EXPERIMENTAL: "1", + PI_SERVER_DIR: serverDir, + }, stdio: ["ignore", "pipe", "pipe"], }); processes.add(child); @@ -93,28 +98,29 @@ afterEach(async () => { describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => { test("a second CLI server takes over the stable coordinator socket", async () => { - const root = await mkdtemp(join(tmpdir(), "pcs-")); + const root = await mkdtemp(join("/tmp", "pcs-")); directories.add(root); const home = join(root, "long-home-segment".repeat(8)); await mkdir(home); const agentDir = join(root, "agent"); + const serverDir = join(root, "server"); await configureExperimentalWorkerModel(agentDir); await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]); - const first = await startServer(home, agentDir); + const first = await startServer(home, agentDir, serverDir); const firstIdentity = await waitForOutput( first, /Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/, ); const firstSocket = await waitForOutput(first, /Socket: (.+\.sock)/); - expect(firstSocket[1]).not.toContain(home); - expect(Buffer.byteLength(firstSocket[1]!)).toBeLessThanOrEqual(103); + expect(firstSocket[1]).toBe(join(serverDir, `${firstIdentity[1]}.sock`)); + expect((await lstat(join(serverDir, "control.sock"))).isSocket()).toBe(true); await runExperimentalClient({ command: "client", sessionId: "demo-1", connect: { transport: "unix", path: firstSocket[1]! }, }); - const replacement = await startServer(home, agentDir); + const replacement = await startServer(home, agentDir, serverDir); const replacementIdentity = await waitForOutput( replacement, /Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/, diff --git a/packages/coding-agent/test/experimental-session-worker.test.ts b/packages/coding-agent/test/experimental-session-worker.test.ts index da1e3f4c4..93125a52e 100644 --- a/packages/coding-agent/test/experimental-session-worker.test.ts +++ b/packages/coding-agent/test/experimental-session-worker.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type ExperimentalSessionWorker, startExperimentalSessionWorker, @@ -14,6 +14,12 @@ const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url const sessionDir = "/tmp/pi-session-worker-tests"; const directories = new Set(); +beforeEach(async () => { + const serverDir = await mkdtemp(join("/tmp", "pi-session-worker-server-")); + directories.add(serverDir); + vi.stubEnv("PI_SERVER_DIR", serverDir); +}); + function testMetadata(id: string): JsonlSessionMetadata { return { id, From 5876bc49f9a83b47193b7a16975a397890f3db0f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 15 Aug 2026 12:20:05 +0200 Subject: [PATCH 7/9] feat(coding-agent): isolate coordinators by server ID --- packages/coding-agent/README.md | 1 + .../docs/environment-variables.md | 1 + packages/coding-agent/src/cli/args.ts | 1 + .../src/cli/experimental/commands/server.ts | 12 +++- .../src/cli/experimental/runtime.ts | 17 +++-- .../src/cli/experimental/server-directory.ts | 1 + .../src/cli/experimental/server-profile.ts | 70 ++++++++++--------- packages/coding-agent/src/main.ts | 5 +- .../test/experimental-cli-command.test.ts | 12 ++++ .../test/experimental-cli-resolution.test.ts | 27 ++++--- .../test/experimental-remote-runtime.test.ts | 44 +++++++++++- .../test/experimental-server-profile.test.ts | 34 +++++++-- .../experimental-server-replacement.test.ts | 2 +- 13 files changed, 164 insertions(+), 63 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1b28ff65e..fc8556035 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -673,6 +673,7 @@ pi --thinking high "Solve this complex problem" | `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) | | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | | `PI_SERVER_DIR` | Override experimental server profile and socket directory (default: `~/.pi/server`) | +| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 9d79d2a25..9a8beb45f 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -82,6 +82,7 @@ These variables are read by Pi itself: | `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` | | `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths | | `PI_SERVER_DIR` | Override the experimental server profile and socket directory; default is `~/.pi/server` | +| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted | | `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 09a3549d8..3d6c389b4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -413,6 +413,7 @@ ${chalk.bold("Environment Variables:")} ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) PI_SERVER_DIR - Experimental server profile and socket directory (default: ~/.pi/server) + PI_SERVER_ID - Logical experimental server ID (overridden by --server-id) PI_OFFLINE - Disable startup network operations when set to 1/true/yes PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) diff --git a/packages/coding-agent/src/cli/experimental/commands/server.ts b/packages/coding-agent/src/cli/experimental/commands/server.ts index 2948928b9..7f160ce23 100644 --- a/packages/coding-agent/src/cli/experimental/commands/server.ts +++ b/packages/coding-agent/src/cli/experimental/commands/server.ts @@ -1,5 +1,6 @@ +import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; import type { AuthInput } from "../auth.ts"; -import { Command, stringOption } from "../command.ts"; +import { Command, stringOption, valueOption } from "../command.ts"; import { authTokenFileOption, authTokenOption, @@ -14,6 +15,7 @@ export interface ServerCommand { readonly command: "server"; readonly auth?: AuthInput; readonly listen?: readonly TransportAddress[]; + readonly serverId?: ServerId; readonly sessionDir?: string; } @@ -22,16 +24,23 @@ export interface ServerCommandContext { } const listenOption = transportOption("--listen"); +const serverIdOption = valueOption("--server-id", (value) => + isServerId(value) + ? { ok: true, value } + : { ok: false, error: `Invalid --server-id "${value}"; expected a lowercase UUIDv4` }, +); const sessionDirOption = stringOption("--session-dir"); export const serverCommand = new Command("server") .option(listenOption) + .option(serverIdOption) .option(sessionDirOption) .option(authTokenOption) .option(authTokenFileOption) .build((input) => { const { auth, errors: authErrors } = parseAuth(input); const listen = input.values(listenOption); + const serverId = input.value(serverIdOption); const sessionDir = input.value(sessionDirOption); const { errors: optionErrors } = parseLegacyOptions(input); const errors = [...authErrors, ...optionErrors, ...unsupportedLegacyOptions("server", input)]; @@ -42,6 +51,7 @@ export const serverCommand = new Command("s command: "server", ...(auth === undefined ? {} : { auth }), ...(listen.length === 0 ? {} : { listen }), + ...(serverId === undefined ? {} : { serverId }), ...(sessionDir === undefined ? {} : { sessionDir }), }, }; diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 83fa5ed0a..32b1587be 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -4,7 +4,7 @@ import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi- import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { PiClient } from "@earendil-works/pi-client"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; -import { isServerId } from "@earendil-works/pi-protocol"; +import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; import type { HostedHarnessHandle, PiServer, PiServerHost } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; import { getAgentDir } from "../../config.ts"; @@ -16,7 +16,7 @@ import { type CoordinatorStartupLease, ensureExperimentalCoordinator, } from "./coordinator-client.ts"; -import { ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts"; +import { ENV_SERVER_ID, ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts"; import { acquireExperimentalServerProfile } from "./server-profile.ts"; import { startExperimentalSessionWorker } from "./session-worker.ts"; @@ -41,7 +41,7 @@ export interface StartExperimentalServerOptions { /** Directory for server sockets. Defaults to PI_SERVER_DIR or ~/.pi/server. */ readonly directory?: string; readonly path?: string; - readonly serverId?: string; + readonly serverId?: ServerId; /** Durable session directory. Defaults to the experimental directory under the configured agent directory. */ readonly sessionDir?: string; } @@ -49,6 +49,8 @@ export interface StartExperimentalServerOptions { export interface StartExperimentalCoordinatedServerOptions { /** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */ readonly directory?: string; + /** Logical service ID. Defaults to PI_SERVER_ID or the directory's default-server-id. */ + readonly serverId?: ServerId; /** Durable session directory. Defaults to the experimental directory under the configured agent directory. */ readonly sessionDir?: string; } @@ -178,7 +180,10 @@ export async function startExperimentalCoordinatedServer( options: StartExperimentalCoordinatedServerOptions = {}, ): Promise { const directory = resolveExperimentalServerDirectory(options.directory); - const { serverId, release } = await acquireExperimentalServerProfile(directory); + const { serverId, release } = await acquireExperimentalServerProfile( + directory, + options.serverId ?? process.env[ENV_SERVER_ID], + ); let backend: ExperimentalServer | undefined; let coordinator: CoordinatorServer | undefined; let startupLease: CoordinatorStartupLease | undefined; @@ -187,8 +192,8 @@ export async function startExperimentalCoordinatedServer( try { await ensurePrivateServerDirectory(directory); const socketPath = getUnixSocketPath(serverId, directory); - const controlPath = join(directory, "control.sock"); - const serverPath = join(directory, `.server-${randomUUID().slice(0, 12)}.sock`); + const controlPath = join(directory, `control-${serverId}.sock`); + const serverPath = join(directory, `.server-${serverId}-${randomUUID().slice(0, 12)}.sock`); startupLease = await ensureExperimentalCoordinator(socketPath, controlPath); coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath }); const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); diff --git a/packages/coding-agent/src/cli/experimental/server-directory.ts b/packages/coding-agent/src/cli/experimental/server-directory.ts index f8d31ec85..48ab1688a 100644 --- a/packages/coding-agent/src/cli/experimental/server-directory.ts +++ b/packages/coding-agent/src/cli/experimental/server-directory.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { resolvePath } from "../../utils/paths.ts"; export const ENV_SERVER_DIR = "PI_SERVER_DIR"; +export const ENV_SERVER_ID = "PI_SERVER_ID"; export function resolveExperimentalServerDirectory(directory?: string): string { return resolvePath(directory ?? process.env[ENV_SERVER_DIR] ?? join(homedir(), ".pi", "server")); diff --git a/packages/coding-agent/src/cli/experimental/server-profile.ts b/packages/coding-agent/src/cli/experimental/server-profile.ts index 9b5379042..5838d6bca 100644 --- a/packages/coding-agent/src/cli/experimental/server-profile.ts +++ b/packages/coding-agent/src/cli/experimental/server-profile.ts @@ -7,55 +7,57 @@ import lockfile from "proper-lockfile"; const LOCK_STALE_MS = 30_000; const LOCK_RETRY_MS = 25; const LOCK_WAIT_MS = 30_000; -const SERVER_ID_FILE = "server-id"; +const DEFAULT_SERVER_ID_FILE = "default-server-id"; export interface ExperimentalServerProfile { readonly serverId: ServerId; release(): Promise; } -/** Lock one experimental server profile and load or create its stable identity. */ -export async function acquireExperimentalServerProfile(directory: string): Promise { +/** Lock one logical server ID in a shared experimental server directory. */ +export async function acquireExperimentalServerProfile( + directory: string, + requestedServerId?: string, +): Promise { await mkdir(directory, { recursive: true, mode: 0o700 }); - const release = await lockfile.lock(directory, { + let serverId: ServerId; + if (requestedServerId !== undefined) { + if (!isServerId(requestedServerId)) throw new Error(`Invalid experimental server ID: ${requestedServerId}`); + serverId = requestedServerId; + } else { + const path = join(directory, DEFAULT_SERVER_ID_FILE); + try { + const value = (await readFile(path, "utf8")).trim(); + if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`); + serverId = value; + } catch (error) { + const code = error instanceof Error && "code" in error ? error.code : undefined; + if (code !== "ENOENT") throw error; + const candidate = randomUUID(); + try { + await writeFile(path, candidate, { encoding: "utf8", mode: 0o600, flag: "wx" }); + serverId = candidate; + } catch (writeError) { + const writeCode = writeError instanceof Error && "code" in writeError ? writeError.code : undefined; + if (writeCode !== "EEXIST") throw writeError; + const value = (await readFile(path, "utf8")).trim(); + if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`); + serverId = value; + } + } + } + + const release = await lockfile.lock(join(directory, `.launcher-${serverId}`), { realpath: false, - lockfilePath: join(directory, ".launcher.lock"), stale: LOCK_STALE_MS, update: LOCK_STALE_MS / 3, retries: { + retries: Math.ceil(LOCK_WAIT_MS / LOCK_RETRY_MS), factor: 1, minTimeout: LOCK_RETRY_MS, maxTimeout: LOCK_RETRY_MS, maxRetryTime: LOCK_WAIT_MS, }, }); - try { - return { serverId: await loadOrCreateServerId(directory), release }; - } catch (error) { - try { - await release(); - } catch (releaseError) { - throw new AggregateError([error, releaseError], "Server profile loading and lock release failed"); - } - throw error; - } -} - -async function loadOrCreateServerId(directory: string): Promise { - const path = join(directory, SERVER_ID_FILE); - try { - const value = (await readFile(path, "utf8")).trim(); - if (!isServerId(value)) throw new Error(`Invalid experimental server identity in ${path}`); - return value; - } catch (error) { - if (!isErrorCode(error, "ENOENT")) throw error; - } - - const serverId = randomUUID(); - await writeFile(path, serverId, { encoding: "utf8", mode: 0o600, flag: "wx" }); - return serverId; -} - -function isErrorCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; + return { serverId, release }; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d2bf234fd..dddff3b0e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -590,7 +590,10 @@ async function waitForTermination(serverClosed: Promise): Promise { async function runExperimentalServerCommand(command: ServerCommand): Promise { if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server"); if (command.listen !== undefined) throw new Error("The local demo server uses its server-addressed Unix socket"); - const runtime = await startExperimentalCoordinatedServer({ sessionDir: command.sessionDir }); + const runtime = await startExperimentalCoordinatedServer({ + serverId: command.serverId, + sessionDir: command.sessionDir, + }); console.log(`Server: ${runtime.serverId}`); console.log(`Socket: ${runtime.socketPath}`); try { diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index a6652d2cb..ab09294c0 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -38,6 +38,16 @@ describe("experimental CLI commands", () => { }); }); + test("parses an experimental server ID", () => { + expect(experimentalCli.parse(["server", "--server-id", "00000000-0000-4000-8000-000000000001"])).toEqual({ + ok: true, + command: { + command: "server", + serverId: "00000000-0000-4000-8000-000000000001", + }, + }); + }); + test("parses an experimental server session directory", () => { expect(experimentalCli.parse(["server", "--session-dir", "~/pi-sessions"])).toEqual({ ok: true, @@ -149,6 +159,8 @@ describe("experimental CLI commands", () => { "The experimental server command does not support existing CLI options yet", ], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], + [["server", "--server-id", "not-a-uuid"], "Invalid --server-id"], + [["server", "--server-id"], "--server-id requires a value"], [["server", "--session-dir"], "--session-dir requires a value"], [ ["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"], diff --git a/packages/coding-agent/test/experimental-cli-resolution.test.ts b/packages/coding-agent/test/experimental-cli-resolution.test.ts index e7ecb203d..16866a610 100644 --- a/packages/coding-agent/test/experimental-cli-resolution.test.ts +++ b/packages/coding-agent/test/experimental-cli-resolution.test.ts @@ -80,19 +80,24 @@ describe("experimental CLI command composition", () => { }); }); - test("passes the server session directory to the command action", async () => { + test("passes server options to the command action", async () => { const runServer = vi.fn(() => undefined); - const result = await experimentalCli.execute(["server", "--session-dir", "./sessions"], { - runPi: vi.fn(() => undefined), - runServer, - runClient: vi.fn(() => undefined), - }); + const result = await experimentalCli.execute( + ["server", "--server-id", "00000000-0000-4000-8000-000000000001", "--session-dir", "./sessions"], + { + runPi: vi.fn(() => undefined), + runServer, + runClient: vi.fn(() => undefined), + }, + ); - expect(result).toEqual({ - ok: true, - command: { command: "server", sessionDir: "./sessions" }, - }); - expect(runServer).toHaveBeenCalledWith({ command: "server", sessionDir: "./sessions" }); + const command = { + command: "server" as const, + serverId: "00000000-0000-4000-8000-000000000001", + sessionDir: "./sessions", + }; + expect(result).toEqual({ ok: true, command }); + expect(runServer).toHaveBeenCalledWith(command); }); test.each(["pi", "server", "client"] as const)("executes the parsed %s command", async (name) => { diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 4b9267938..d5596bff5 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -64,17 +64,20 @@ describe("experimental durable server composition", () => { expect(runtime.workerPids.size).toBe(0); }); - test("uses PI_SERVER_DIR for sockets and discovery", async () => { + test("uses PI_SERVER_DIR and PI_SERVER_ID", async () => { const directory = await mkdtemp(join("/tmp", "pi-server-dir-")); directories.add(directory); + const serverId = "00000000-0000-4000-8000-000000000001"; vi.stubEnv("PI_SERVER_DIR", directory); + vi.stubEnv("PI_SERVER_ID", serverId); const runtime = await startExperimentalCoordinatedServer(); servers.add(runtime); - expect(runtime.socketPath).toBe(join(directory, `${runtime.serverId}.sock`)); + expect(runtime.serverId).toBe(serverId); + expect(runtime.socketPath).toBe(join(directory, `${serverId}.sock`)); expect((await lstat(directory)).mode & 0o777).toBe(0o700); const publicSocket = await lstat(runtime.socketPath); - const controlSocket = await lstat(join(directory, "control.sock")); + const controlSocket = await lstat(join(directory, `control-${runtime.serverId}.sock`)); expect(publicSocket.isSocket()).toBe(true); expect(publicSocket.mode & 0o777).toBe(0o600); expect(controlSocket.isSocket()).toBe(true); @@ -85,6 +88,41 @@ describe("experimental durable server composition", () => { }); }); + test("runs and discovers multiple logical servers from one directory", async () => { + const directory = await mkdtemp(join("/tmp", "pi-multi-server-")); + directories.add(directory); + const firstId = "00000000-0000-4000-8000-000000000001"; + const secondId = "00000000-0000-4000-8000-000000000002"; + const [first, second] = await Promise.all([ + startExperimentalCoordinatedServer({ directory, serverId: firstId }), + startExperimentalCoordinatedServer({ directory, serverId: secondId }), + ]); + servers.add(first); + servers.add(second); + + expect((await lstat(join(directory, `control-${firstId}.sock`))).isSocket()).toBe(true); + expect((await lstat(join(directory, `control-${secondId}.sock`))).isSocket()).toBe(true); + await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({ + kind: "list", + sessions: [ + { serverId: firstId, sessionId: "demo-1" }, + { serverId: firstId, sessionId: "demo-2" }, + { serverId: secondId, sessionId: "demo-1" }, + { serverId: secondId, sessionId: "demo-2" }, + ], + }); + + await first.close(); + await expect.poll(() => pathExists(first.socketPath)).toBe(false); + await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({ + kind: "list", + sessions: [ + { serverId: secondId, sessionId: "demo-1" }, + { serverId: secondId, sessionId: "demo-2" }, + ], + }); + }); + test("discovers and lists seeded sessions without hosting either session", async () => { const { directory, runtime } = await makeServer(); diff --git a/packages/coding-agent/test/experimental-server-profile.test.ts b/packages/coding-agent/test/experimental-server-profile.test.ts index d53107a2e..f6484853d 100644 --- a/packages/coding-agent/test/experimental-server-profile.test.ts +++ b/packages/coding-agent/test/experimental-server-profile.test.ts @@ -1,10 +1,12 @@ -import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { acquireExperimentalServerProfile } from "../src/cli/experimental/server-profile.ts"; const directories = new Set(); +const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001"; +const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002"; async function makeDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "psp-")); @@ -34,10 +36,22 @@ describe("experimental server profile", () => { const second = await pendingSecond; expect(secondAcquired).toBe(true); expect(second.serverId).toBe(first.serverId); + expect((await readFile(join(directory, "default-server-id"), "utf8")).trim()).toBe(first.serverId); await second.release(); }); - test("does not serialize or share identity across server profiles", async () => { + test("does not serialize different server IDs in one directory", async () => { + const directory = await makeDirectory(); + const [first, second] = await Promise.all([ + acquireExperimentalServerProfile(directory, FIRST_SERVER_ID), + acquireExperimentalServerProfile(directory, SECOND_SERVER_ID), + ]); + expect(first.serverId).toBe(FIRST_SERVER_ID); + expect(second.serverId).toBe(SECOND_SERVER_ID); + await Promise.all([first.release(), second.release()]); + }); + + test("does not share the default identity across server directories", async () => { const firstDirectory = await makeDirectory(); const secondDirectory = await makeDirectory(); @@ -49,11 +63,19 @@ describe("experimental server profile", () => { await Promise.all([first.release(), second.release()]); }); - test("rejects corrupt identity and releases the launcher lock", async () => { + test("rejects a corrupt default identity", async () => { const directory = await makeDirectory(); - await writeFile(join(directory, "server-id"), "invalid\n"); + await writeFile(join(directory, "default-server-id"), "invalid\n"); - await expect(acquireExperimentalServerProfile(directory)).rejects.toThrow(/Invalid experimental server identity/); - await expect(access(join(directory, ".launcher.lock"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(acquireExperimentalServerProfile(directory)).rejects.toThrow( + /Invalid default experimental server identity/, + ); + }); + + test("rejects an invalid explicit server ID", async () => { + const directory = await makeDirectory(); + await expect(acquireExperimentalServerProfile(directory, "invalid")).rejects.toThrow( + "Invalid experimental server ID", + ); }); }); diff --git a/packages/coding-agent/test/experimental-server-replacement.test.ts b/packages/coding-agent/test/experimental-server-replacement.test.ts index c6cc08275..ad532cc82 100644 --- a/packages/coding-agent/test/experimental-server-replacement.test.ts +++ b/packages/coding-agent/test/experimental-server-replacement.test.ts @@ -113,7 +113,7 @@ describe.skipIf(process.platform === "win32")("experimental CLI server replaceme ); const firstSocket = await waitForOutput(first, /Socket: (.+\.sock)/); expect(firstSocket[1]).toBe(join(serverDir, `${firstIdentity[1]}.sock`)); - expect((await lstat(join(serverDir, "control.sock"))).isSocket()).toBe(true); + expect((await lstat(join(serverDir, `control-${firstIdentity[1]}.sock`))).isSocket()).toBe(true); await runExperimentalClient({ command: "client", sessionId: "demo-1", From 9ad77a2c6ba0769ed14e91bbe4f754bf7607bf1e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 15 Aug 2026 12:26:28 +0200 Subject: [PATCH 8/9] fix: remove redundant hidden Unix socket entries --- packages/coding-agent/src/cli/experimental/runtime.ts | 3 ++- .../src/cli/experimental/server-profile.ts | 2 +- .../src/cli/experimental/session-worker.ts | 2 +- .../test/experimental-remote-runtime.test.ts | 8 +++++++- .../test/experimental-server-profile.test.ts | 6 +++++- .../test/experimental-session-worker.test.ts | 8 ++++++-- packages/server/src/transports/unix/listener.ts | 11 ++++++----- packages/server/test/unix.test.ts | 5 +++-- 8 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 32b1587be..c95571744 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -193,7 +193,8 @@ export async function startExperimentalCoordinatedServer( await ensurePrivateServerDirectory(directory); const socketPath = getUnixSocketPath(serverId, directory); const controlPath = join(directory, `control-${serverId}.sock`); - const serverPath = join(directory, `.server-${serverId}-${randomUUID().slice(0, 12)}.sock`); + const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12); + const serverPath = join(directory, `server-${serverId}-${serverNonce}.sock`); startupLease = await ensureExperimentalCoordinator(socketPath, controlPath); coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath }); const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); diff --git a/packages/coding-agent/src/cli/experimental/server-profile.ts b/packages/coding-agent/src/cli/experimental/server-profile.ts index 5838d6bca..60ba00a28 100644 --- a/packages/coding-agent/src/cli/experimental/server-profile.ts +++ b/packages/coding-agent/src/cli/experimental/server-profile.ts @@ -47,7 +47,7 @@ export async function acquireExperimentalServerProfile( } } - const release = await lockfile.lock(join(directory, `.launcher-${serverId}`), { + const release = await lockfile.lock(join(directory, `launcher-${serverId}`), { realpath: false, stale: LOCK_STALE_MS, update: LOCK_STALE_MS / 3, diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index e01da838d..09bea8519 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -97,7 +97,7 @@ export async function startExperimentalSessionWorker( } else { const controlDirectory = resolveExperimentalServerDirectory(options.controlDirectory); await ensurePrivateServerDirectory(controlDirectory); - controlAddress = join(controlDirectory, `.worker-${controlId}.sock`); + controlAddress = join(controlDirectory, `worker-${controlId}.sock`); } const control = await createWorkerControlServer(controlAddress, token, metadata.id); let child: ChildProcess; diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index d5596bff5..61dcf035b 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,4 +1,4 @@ -import { chmod, lstat, mkdtemp, rm } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, readdir, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { PiClient } from "@earendil-works/pi-client"; import { createUnixTransportFactory } from "@earendil-works/pi-client/unix"; @@ -82,6 +82,12 @@ describe("experimental durable server composition", () => { expect(publicSocket.mode & 0o777).toBe(0o600); expect(controlSocket.isSocket()).toBe(true); expect(controlSocket.mode & 0o777).toBe(0o600); + const entries = await readdir(directory); + expect(entries).toHaveLength(3); + expect(entries.every((entry) => !entry.startsWith("."))).toBe(true); + expect(entries).toContain(`${serverId}.sock`); + expect(entries).toContain(`control-${serverId}.sock`); + expect(entries).toContainEqual(expect.stringMatching(new RegExp(`^server-${serverId}-[0-9a-f]{12}\\.sock$`))); await expect(runExperimentalClient({ command: "client" })).resolves.toMatchObject({ kind: "list", sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }], diff --git a/packages/coding-agent/test/experimental-server-profile.test.ts b/packages/coding-agent/test/experimental-server-profile.test.ts index f6484853d..4ff4a7bcf 100644 --- a/packages/coding-agent/test/experimental-server-profile.test.ts +++ b/packages/coding-agent/test/experimental-server-profile.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -48,6 +48,10 @@ describe("experimental server profile", () => { ]); expect(first.serverId).toBe(FIRST_SERVER_ID); expect(second.serverId).toBe(SECOND_SERVER_ID); + expect((await readdir(directory)).sort()).toEqual([ + `launcher-${FIRST_SERVER_ID}.lock`, + `launcher-${SECOND_SERVER_ID}.lock`, + ]); await Promise.all([first.release(), second.release()]); }); diff --git a/packages/coding-agent/test/experimental-session-worker.test.ts b/packages/coding-agent/test/experimental-session-worker.test.ts index 93125a52e..c64b35274 100644 --- a/packages/coding-agent/test/experimental-session-worker.test.ts +++ b/packages/coding-agent/test/experimental-session-worker.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; @@ -13,9 +13,10 @@ const workers = new Set(); const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url); const sessionDir = "/tmp/pi-session-worker-tests"; const directories = new Set(); +let serverDir: string; beforeEach(async () => { - const serverDir = await mkdtemp(join("/tmp", "pi-session-worker-server-")); + serverDir = await mkdtemp(join("/tmp", "pi-session-worker-server-")); directories.add(serverDir); vi.stubEnv("PI_SERVER_DIR", serverDir); }); @@ -50,6 +51,9 @@ describe("experimental session worker controller", () => { expect(worker.sessionId).toBe("ready"); expect(worker.pid).not.toBe(process.pid); expect(processExists(worker.pid)).toBe(true); + const entries = await readdir(serverDir); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatch(/^worker-[0-9a-f-]+\.sock$/); await Promise.all([worker.close(), worker.close()]); expect(processExists(worker.pid)).toBe(false); diff --git a/packages/server/src/transports/unix/listener.ts b/packages/server/src/transports/unix/listener.ts index ba92c30a1..9d370b858 100644 --- a/packages/server/src/transports/unix/listener.ts +++ b/packages/server/src/transports/unix/listener.ts @@ -85,6 +85,8 @@ class UnixListener implements PiServerListener { this.socketIdentity = { dev: stats.dev, ino: stats.ino }; await link(ownedBindPath, this.path); await setSocketMode(this.path, this.mode); + await removePath(ownedBindPath); + this.ownedBindPath = undefined; } catch (error) { await this.closeServerAndCleanup(server); this.server = undefined; @@ -144,8 +146,7 @@ class UnixListener implements PiServerListener { try { await closeNetServer(server, (error) => this.reportError(error)); } finally { - // Remove the private bind path before the public route. Launchers use the - // public route disappearing as the signal that a successor may bind safely. + // Remove an unpublished startup bind path before the public route. if (this.ownedBindPath) await removePath(this.ownedBindPath); this.ownedBindPath = undefined; await this.cleanupOwnedSocket(); @@ -165,7 +166,7 @@ class UnixListener implements PiServerListener { } if (!current.isSocket() || current.dev !== identity.dev || current.ino !== identity.ino) return; - const preserved = join(dirname(this.path), `.c-${randomUUID().slice(0, 6)}`); + const preserved = join(dirname(this.path), `cleanup-${randomUUID().slice(0, 6)}`); try { await rename(this.path, preserved); } catch (error) { @@ -301,7 +302,7 @@ export class UnixByteConnection implements ByteConnection { function getOwnedBindPath(path: string): string { const suffix = createHash("sha256").update(path).digest("hex").slice(0, 8); - return join(dirname(path), `.p-${suffix}`); + return join(dirname(path), `bind-${suffix}`); } async function removeStaleSocket(path: string): Promise { @@ -315,7 +316,7 @@ async function removeStaleSocket(path: string): Promise { if (!original.isSocket()) throw new Error(`Refusing to remove non-socket Unix listener path: ${path}`); if (await isSocketLive(path)) throw new Error(`Unix listener is already running: ${path}`); - const preserved = join(dirname(path), `.s-${randomUUID().slice(0, 6)}`); + const preserved = join(dirname(path), `stale-${randomUUID().slice(0, 6)}`); try { await rename(path, preserved); } catch (error) { diff --git a/packages/server/test/unix.test.ts b/packages/server/test/unix.test.ts index 7d71ddbfb..deb7fe573 100644 --- a/packages/server/test/unix.test.ts +++ b/packages/server/test/unix.test.ts @@ -1,7 +1,7 @@ import { type ChildProcess, fork } from "node:child_process"; import { once } from "node:events"; -import { lstat, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { lstat, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import type { PiServer } from "../src/index.ts"; import { connectUnixTestClient, type ProtocolTestClient, TestServerHost } from "../src/testing/index.ts"; @@ -100,6 +100,7 @@ describe("Unix listener filesystem lifecycle", () => { const stats = await lstat(path); expect(stats.isSocket()).toBe(true); if (process.platform !== "win32") expect(stats.mode & 0o777).toBe(0o600); + expect(await readdir(dirname(path))).toEqual(["server.sock"]); await server.close(); await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); From 312c5bd7001e2ca8779b914a33e09b84cb752b14 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 15 Aug 2026 15:51:37 +0200 Subject: [PATCH 9/9] fix(coding-agent): launch internal processes portably --- packages/coding-agent/src/bun/cli.ts | 19 ++++--- .../coding-agent/src/bun/register-bedrock.ts | 4 -- .../coding-agent/src/bun/runtime-setup.ts | 9 ++++ packages/coding-agent/src/cli.ts | 32 ++++++------ .../coordinated-session-workers.ts | 13 ++--- .../cli/experimental/coordinator-client.ts | 22 +-------- .../experimental/coordinator-process-entry.ts | 11 +++++ .../cli/experimental/coordinator-process.ts | 27 ++++++---- .../experimental/internal-process-launcher.ts | 46 +++++++++++++++++ .../src/cli/experimental/internal-process.ts | 18 +++++++ .../session-worker-process-entry.ts | 10 ++++ .../experimental/session-worker-process.ts | 28 ++++++----- .../src/cli/experimental/session-worker.ts | 48 ++---------------- .../experimental-internal-process.test.ts | 49 +++++++++++++++++++ .../experimental-server-replacement.test.ts | 2 +- 15 files changed, 214 insertions(+), 124 deletions(-) delete mode 100644 packages/coding-agent/src/bun/register-bedrock.ts create mode 100644 packages/coding-agent/src/bun/runtime-setup.ts create mode 100644 packages/coding-agent/src/cli/experimental/coordinator-process-entry.ts create mode 100644 packages/coding-agent/src/cli/experimental/internal-process-launcher.ts create mode 100644 packages/coding-agent/src/cli/experimental/internal-process.ts create mode 100644 packages/coding-agent/src/cli/experimental/session-worker-process-entry.ts create mode 100644 packages/coding-agent/test/experimental-internal-process.test.ts diff --git a/packages/coding-agent/src/bun/cli.ts b/packages/coding-agent/src/bun/cli.ts index d255024c1..42cce12b8 100644 --- a/packages/coding-agent/src/bun/cli.ts +++ b/packages/coding-agent/src/bun/cli.ts @@ -1,15 +1,14 @@ #!/usr/bin/env node -import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; -import { APP_NAME } from "../config.ts"; - -process.title = APP_NAME; -process.emitWarning = (() => {}) as typeof process.emitWarning; - -registerBunOAuthFlows(); - +import { runCoordinatorProcess } from "../cli/experimental/coordinator-process.ts"; +import { consumeInternalProcessRole, getInternalProcessRole } from "../cli/experimental/internal-process.ts"; import { restoreSandboxEnv } from "./restore-sandbox-env.ts"; restoreSandboxEnv(); -await import("./register-bedrock.ts"); -await import("../cli.ts"); +if (getInternalProcessRole() === "coordinator") { + consumeInternalProcessRole(); + await runCoordinatorProcess(process.argv.slice(2)); +} else { + await import("./runtime-setup.ts"); + await import("../cli.ts"); +} diff --git a/packages/coding-agent/src/bun/register-bedrock.ts b/packages/coding-agent/src/bun/register-bedrock.ts deleted file mode 100644 index 18e80dcc0..000000000 --- a/packages/coding-agent/src/bun/register-bedrock.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider"; -import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat"; - -setBedrockProviderModule(bedrockProviderModule); diff --git a/packages/coding-agent/src/bun/runtime-setup.ts b/packages/coding-agent/src/bun/runtime-setup.ts new file mode 100644 index 000000000..8d09bf5a6 --- /dev/null +++ b/packages/coding-agent/src/bun/runtime-setup.ts @@ -0,0 +1,9 @@ +import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider"; +import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; +import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat"; +import { APP_NAME } from "../config.ts"; + +process.title = APP_NAME; +process.emitWarning = (() => {}) as typeof process.emitWarning; +registerBunOAuthFlows(); +setBedrockProviderModule(bedrockProviderModule); diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index caa447229..0afe70a41 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -1,21 +1,25 @@ #!/usr/bin/env node -/** - * CLI entry point for the refactored coding agent. - * Uses main.ts with AgentSession and new mode modules. - * - * Test with: npx tsx src/cli-new.ts [args...] - */ +import { consumeInternalProcessRole } from "./cli/experimental/internal-process.ts"; +import { runSessionWorkerProcess } from "./cli/experimental/session-worker-process.ts"; import { APP_NAME } from "./config.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { main } from "./main.ts"; -process.title = APP_NAME; -process.env.PI_CODING_AGENT = "true"; -process.env.AI_AGENT = "pi"; -process.emitWarning = (() => {}) as typeof process.emitWarning; +const internalProcessRole = consumeInternalProcessRole(); +if (internalProcessRole === "session-worker") { + void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1)); +} else { + if (internalProcessRole !== undefined) { + throw new Error(`Internal ${internalProcessRole} process must use its lightweight entrypoint`); + } + process.title = APP_NAME; + process.env.PI_CODING_AGENT = "true"; + process.env.AI_AGENT = "pi"; + process.emitWarning = (() => {}) as typeof process.emitWarning; -// Configure undici's global dispatcher before provider SDKs issue requests. -// Runtime settings are applied once SettingsManager has loaded global/project settings. -configureHttpDispatcher(); + // Configure undici's global dispatcher before provider SDKs issue requests. + // Runtime settings are applied once SettingsManager has loaded global/project settings. + configureHttpDispatcher(); -main(process.argv.slice(2)); + main(process.argv.slice(2)); +} diff --git a/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts index c5eafd4ae..d6747a512 100644 --- a/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts +++ b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { isAbsolute } from "node:path"; import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; @@ -6,8 +6,8 @@ import type { HostedHarnessHandle } from "@earendil-works/pi-server"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; import type { CoordinatorServer, CoordinatorServerEvent } from "./coordinator-client.ts"; +import { spawnInternalProcess } from "./internal-process-launcher.ts"; import { - createExperimentalSessionWorkerLaunchSpec, SESSION_WORKER_CONTROL_ADDRESS_ENV, SESSION_WORKER_CONTROL_TOKEN_ENV, SESSION_WORKER_COORDINATED_ENV, @@ -182,24 +182,17 @@ export class CoordinatedSessionWorkers { const sessionKey = metadata.path; const peerId = `worker-${randomUUID()}`; const token = randomUUID(); - const launch = createExperimentalSessionWorkerLaunchSpec(metadata, { sessionDir: this.#sessionDir }); let child: ChildProcess; try { - child = spawn(launch.command, launch.args, { - cwd: launch.cwd, - detached: true, + child = spawnInternalProcess("session-worker", [this.#sessionDir, JSON.stringify(metadata)], { env: { - ...launch.env, [SESSION_WORKER_CONTROL_ADDRESS_ENV]: this.#coordinator.controlPath, [SESSION_WORKER_CONTROL_TOKEN_ENV]: token, [SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(sessionKey).toString("base64url"), [SESSION_WORKER_COORDINATED_ENV]: "1", [SESSION_WORKER_PEER_ID_ENV]: peerId, }, - stdio: "ignore", - windowsHide: true, }); - child.unref(); } catch (error) { return Promise.reject(error); } diff --git a/packages/coding-agent/src/cli/experimental/coordinator-client.ts b/packages/coding-agent/src/cli/experimental/coordinator-client.ts index bdce86193..265cd4ac7 100644 --- a/packages/coding-agent/src/cli/experimental/coordinator-client.ts +++ b/packages/coding-agent/src/cli/experimental/coordinator-client.ts @@ -1,9 +1,8 @@ -import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createConnection, type Socket } from "node:net"; -import { fileURLToPath } from "node:url"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; +import { spawnInternalProcess } from "./internal-process-launcher.ts"; const COORDINATOR_PROTOCOL_VERSION = 1; const COORDINATOR_START_TIMEOUT_MS = 10_000; @@ -182,27 +181,10 @@ export interface CoordinatorStartupLease { export async function ensureExperimentalCoordinator( publicPath: string, controlPath: string, - coordinatorUrl = new URL( - import.meta.url.endsWith(".js") ? "coordinator-process.js" : "coordinator-process.ts", - import.meta.url, - ), ): Promise { const existing = await tryConnect(controlPath); if (existing) return { close: () => existing.destroy() }; - const args = [ - ...(coordinatorUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : []), - fileURLToPath(coordinatorUrl), - publicPath, - controlPath, - ]; - const child = spawn(process.execPath, args, { - cwd: process.cwd(), - detached: true, - env: process.env, - stdio: "ignore", - windowsHide: true, - }); - child.unref(); + const child = spawnInternalProcess("coordinator", [publicPath, controlPath]); const deadline = Date.now() + COORDINATOR_START_TIMEOUT_MS; while (true) { const socket = await tryConnect(controlPath); diff --git a/packages/coding-agent/src/cli/experimental/coordinator-process-entry.ts b/packages/coding-agent/src/cli/experimental/coordinator-process-entry.ts new file mode 100644 index 000000000..4deaa25c2 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinator-process-entry.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { runCoordinatorProcess } from "./coordinator-process.ts"; +import { consumeInternalProcessRole } from "./internal-process.ts"; + +const role = consumeInternalProcessRole(); +if (role !== "coordinator") throw new Error("Coordinator entrypoint requires an internal coordinator invocation"); + +void runCoordinatorProcess(process.argv.slice(2)).catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/coding-agent/src/cli/experimental/coordinator-process.ts b/packages/coding-agent/src/cli/experimental/coordinator-process.ts index c30ea8351..0a881bb29 100644 --- a/packages/coding-agent/src/cli/experimental/coordinator-process.ts +++ b/packages/coding-agent/src/cli/experimental/coordinator-process.ts @@ -30,9 +30,9 @@ interface ControlMessage { readonly payload?: unknown; } -const publicPath = process.argv[2]; -const controlPath = process.argv[3]; -if (!publicPath || !controlPath) throw new Error("Coordinator requires public and control socket paths"); +let publicPath: string; +let controlPath: string; +let running = false; const peers = new Map(); const controlConnections = new Set(); @@ -44,6 +44,20 @@ let emptyTimer: NodeJS.Timeout | undefined; const publicServer = createServer((socket) => acceptPublicConnection(socket)); const controlServer = createServer((socket) => acceptControlConnection(socket)); +export async function runCoordinatorProcess(args: readonly string[]): Promise { + if (running) throw new Error("Coordinator process is already running"); + const [requestedPublicPath, requestedControlPath] = args; + if (!requestedPublicPath || !requestedControlPath) { + throw new Error("Coordinator requires public and control socket paths"); + } + publicPath = requestedPublicPath; + controlPath = requestedControlPath; + running = true; + process.once("SIGINT", () => void shutdownCoordinator()); + process.once("SIGTERM", () => void shutdownCoordinator()); + await main(); +} + async function main(): Promise { await removeStaleSocket(controlPath); await removeStaleSocket(publicPath); @@ -320,10 +334,3 @@ async function cleanupSocket(path: string): Promise { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; }); } - -process.once("SIGINT", () => void shutdownCoordinator()); -process.once("SIGTERM", () => void shutdownCoordinator()); -void main().catch((error: unknown) => { - console.error(error); - process.exit(1); -}); diff --git a/packages/coding-agent/src/cli/experimental/internal-process-launcher.ts b/packages/coding-agent/src/cli/experimental/internal-process-launcher.ts new file mode 100644 index 000000000..b3ff7e2bf --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/internal-process-launcher.ts @@ -0,0 +1,46 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { isBunBinary } from "../../config.ts"; +import { INTERNAL_PROCESS_ENV, type InternalProcessRole } from "./internal-process.ts"; + +export interface InternalProcessSpawnOptions { + readonly entryUrl?: URL; + readonly env?: NodeJS.ProcessEnv; +} + +/** Spawn a detached Pi-owned process consistently across Node and compiled Bun. */ +export function spawnInternalProcess( + role: InternalProcessRole, + args: readonly string[], + options: InternalProcessSpawnOptions = {}, +): ChildProcess { + if (isBunBinary && options.entryUrl) { + throw new Error("A compiled Bun executable cannot launch an external internal-process entrypoint"); + } + const child = spawn( + process.execPath, + isBunBinary ? [...args] : [fileURLToPath(defaultEntryUrl(role, options.entryUrl)), ...args], + { + cwd: process.cwd(), + detached: true, + env: { + ...process.env, + ...options.env, + [INTERNAL_PROCESS_ENV]: role, + }, + stdio: "ignore", + windowsHide: true, + }, + ); + child.unref(); + return child; +} + +function defaultEntryUrl(role: InternalProcessRole, override: URL | undefined): URL { + if (override) return override; + const javaScript = import.meta.url.endsWith(".js"); + if (role === "coordinator") { + return new URL(javaScript ? "coordinator-process-entry.js" : "coordinator-process-entry.ts", import.meta.url); + } + return new URL(javaScript ? "session-worker-process-entry.js" : "session-worker-process-entry.ts", import.meta.url); +} diff --git a/packages/coding-agent/src/cli/experimental/internal-process.ts b/packages/coding-agent/src/cli/experimental/internal-process.ts new file mode 100644 index 000000000..c049016fb --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/internal-process.ts @@ -0,0 +1,18 @@ +export const INTERNAL_PROCESS_ENV = "__PI_INTERNAL_SPAWN"; + +export type InternalProcessRole = "coordinator" | "session-worker"; + +/** Read and validate an internal process role without consuming it. */ +export function getInternalProcessRole(): InternalProcessRole | undefined { + const role = process.env[INTERNAL_PROCESS_ENV]; + if (role === undefined) return undefined; + if (role === "coordinator" || role === "session-worker") return role; + throw new Error(`Unsupported internal process role: ${role}`); +} + +/** Read, validate, and remove the role so descendants do not inherit it. */ +export function consumeInternalProcessRole(): InternalProcessRole | undefined { + const role = getInternalProcessRole(); + delete process.env[INTERNAL_PROCESS_ENV]; + return role; +} diff --git a/packages/coding-agent/src/cli/experimental/session-worker-process-entry.ts b/packages/coding-agent/src/cli/experimental/session-worker-process-entry.ts new file mode 100644 index 000000000..852bf4b4e --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/session-worker-process-entry.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { consumeInternalProcessRole } from "./internal-process.ts"; +import { runSessionWorkerProcess } from "./session-worker-process.ts"; + +const role = consumeInternalProcessRole(); +if (role !== "session-worker") { + throw new Error("Session worker entrypoint requires an internal session-worker invocation"); +} + +void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1)); diff --git a/packages/coding-agent/src/cli/experimental/session-worker-process.ts b/packages/coding-agent/src/cli/experimental/session-worker-process.ts index e8e608504..7078bd93b 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker-process.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker-process.ts @@ -218,10 +218,10 @@ async function closeResources(resources: { if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed"); } -async function run(): Promise { - const sessionDir = process.argv[2]; +async function run(args: readonly string[]): Promise { + const sessionDir = args[0]; if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory"); - const metadata = parseMetadata(process.argv[3]); + const metadata = parseMetadata(args[1]); const sessionId = metadata.id; const control = await connectControl(); const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!; @@ -311,13 +311,17 @@ async function run(): Promise { } } -void run().catch(async (error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]; - const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV]; - if (token && encodedSessionKey) { - const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString(); - await failureControl?.send({ type: "worker_failed", token, sessionKey, message }).catch(() => {}); +export async function runSessionWorkerProcess(args: readonly string[]): Promise { + try { + await run(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]; + const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV]; + if (token && encodedSessionKey) { + const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString(); + await failureControl?.send({ type: "worker_failed", token, sessionKey, message }).catch(() => {}); + } + throw error; } - process.exit(1); -}); +} diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index 09bea8519..33f604072 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -1,12 +1,12 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, unlink } from "node:fs/promises"; import { createServer, type Server, type Socket } from "node:net"; import { isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; +import { spawnInternalProcess } from "./internal-process-launcher.ts"; import { ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts"; import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts"; @@ -38,13 +38,6 @@ export interface ExperimentalSessionWorker { close(): Promise; } -export interface SessionWorkerLaunchSpec { - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly env: Readonly>; -} - export interface StartExperimentalSessionWorkerOptions { readonly sessionDir: string; readonly controlDirectory?: string; @@ -53,32 +46,6 @@ export interface StartExperimentalSessionWorkerOptions { readonly workerUrl?: URL; } -export function createExperimentalSessionWorkerLaunchSpec( - metadata: JsonlSessionMetadata, - options: Pick, -): SessionWorkerLaunchSpec { - if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); - const workerUrl = - options.workerUrl ?? - new URL( - import.meta.url.endsWith(".js") ? "session-worker-process.js" : "session-worker-process.ts", - import.meta.url, - ); - return { - command: process.execPath, - args: [ - ...(workerUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : []), - fileURLToPath(workerUrl), - options.sessionDir, - JSON.stringify(metadata), - ], - cwd: process.cwd(), - env: Object.fromEntries( - Object.entries(process.env).flatMap(([name, value]) => (value === undefined ? [] : [[name, value]])), - ), - }; -} - export async function startExperimentalSessionWorker( metadata: JsonlSessionMetadata, options: StartExperimentalSessionWorkerOptions, @@ -88,7 +55,7 @@ export async function startExperimentalSessionWorker( options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs", ); - const launch = createExperimentalSessionWorkerLaunchSpec(metadata, options); + if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); const token = randomUUID(); const controlId = randomUUID(); let controlAddress: string; @@ -102,19 +69,14 @@ export async function startExperimentalSessionWorker( const control = await createWorkerControlServer(controlAddress, token, metadata.id); let child: ChildProcess; try { - child = spawn(launch.command, launch.args, { - cwd: launch.cwd, - detached: true, + child = spawnInternalProcess("session-worker", [options.sessionDir, JSON.stringify(metadata)], { + entryUrl: options.workerUrl, env: { - ...launch.env, [SESSION_WORKER_CONTROL_ADDRESS_ENV]: controlAddress, [SESSION_WORKER_CONTROL_TOKEN_ENV]: token, [SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(metadata.path).toString("base64url"), }, - stdio: "ignore", - windowsHide: true, }); - child.unref(); } catch (error) { await control.close(); throw error; diff --git a/packages/coding-agent/test/experimental-internal-process.test.ts b/packages/coding-agent/test/experimental-internal-process.test.ts new file mode 100644 index 000000000..e9a96faa1 --- /dev/null +++ b/packages/coding-agent/test/experimental-internal-process.test.ts @@ -0,0 +1,49 @@ +import type { ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { spawnInternalProcess } from "../src/cli/experimental/internal-process-launcher.ts"; + +const children = new Set(); +const directories = new Set(); + +afterEach(async () => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGTERM"); + await exited; + } + } + children.clear(); + await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true }))); + directories.clear(); +}); + +describe.skipIf(process.platform === "win32")("experimental internal process launcher", () => { + test("starts the coordinator with the native runtime", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-internal-process-")); + directories.add(directory); + const publicPath = join(directory, "public.sock"); + const controlPath = join(directory, "control.sock"); + const child = spawnInternalProcess("coordinator", [publicPath, controlPath]); + children.add(child); + + await expect.poll(() => canConnect(controlPath)).toBe(true); + expect(child.pid).not.toBe(process.pid); + }); +}); + +function canConnect(path: string): Promise { + return new Promise((resolve) => { + const socket = createConnection(path); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} diff --git a/packages/coding-agent/test/experimental-server-replacement.test.ts b/packages/coding-agent/test/experimental-server-replacement.test.ts index ad532cc82..63af4f734 100644 --- a/packages/coding-agent/test/experimental-server-replacement.test.ts +++ b/packages/coding-agent/test/experimental-server-replacement.test.ts @@ -18,7 +18,7 @@ interface RunningCli { } async function startServer(home: string, agentDir: string, serverDir: string): Promise { - const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], { + const child = spawn(process.execPath, [cliPath, "server"], { cwd: fileURLToPath(new URL("../../..", import.meta.url)), env: { ...process.env,