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/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/README.md b/packages/coding-agent/README.md index e6e43c2eb..fc8556035 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -672,6 +672,8 @@ 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_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 29c324f63..9a8beb45f 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -81,6 +81,8 @@ 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_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/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/args.ts b/packages/coding-agent/src/cli/args.ts index 3c0e01b3c..3d6c389b4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -412,6 +412,8 @@ ${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_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 bf5147754..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 } from "../command.ts"; +import { Command, stringOption, valueOption } from "../command.ts"; import { authTokenFileOption, authTokenOption, @@ -14,6 +15,8 @@ export interface ServerCommand { readonly command: "server"; readonly auth?: AuthInput; readonly listen?: readonly TransportAddress[]; + readonly serverId?: ServerId; + readonly sessionDir?: string; } export interface ServerCommandContext { @@ -21,14 +24,24 @@ 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)]; if (errors.length > 0) return { ok: false, errors }; @@ -38,6 +51,8 @@ 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/coordinated-session-workers.ts b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts new file mode 100644 index 000000000..d6747a512 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinated-session-workers.ts @@ -0,0 +1,346 @@ +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"; +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 { + 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(); + let child: ChildProcess; + try { + child = spawnInternalProcess("session-worker", [this.#sessionDir, JSON.stringify(metadata)], { + 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, + }, + }); + } 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..265cd4ac7 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinator-client.ts @@ -0,0 +1,255 @@ +import { randomUUID } from "node:crypto"; +import { createConnection, type Socket } from "node:net"; +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; +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 interface CoordinatorStartupLease { + close(): void; +} + +export async function ensureExperimentalCoordinator( + publicPath: string, + controlPath: string, +): Promise { + const existing = await tryConnect(controlPath); + if (existing) return { close: () => existing.destroy() }; + const child = spawnInternalProcess("coordinator", [publicPath, controlPath]); + 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-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 new file mode 100644 index 000000000..0a881bb29 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/coordinator-process.ts @@ -0,0 +1,336 @@ +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; +} + +let publicPath: string; +let controlPath: string; +let running = false; + +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)); + +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); + 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; + }); +} 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/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/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts index 8622b050e..c95571744 100644 --- a/packages/coding-agent/src/cli/experimental/runtime.ts +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -1,26 +1,28 @@ 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 { 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"; -import { isServerId } from "@earendil-works/pi-protocol"; -import type { PiServer, PiServerHost } from "@earendil-works/pi-server"; +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"; +import { resolvePath } from "../../utils/paths.ts"; import type { ClientCommand } from "./commands/client.ts"; -import { DEMO_SESSION_IDS } from "./demo-sessions.ts"; +import { CoordinatedSessionWorkers } from "./coordinated-session-workers.ts"; +import { + CoordinatorServer, + type CoordinatorStartupLease, + ensureExperimentalCoordinator, +} from "./coordinator-client.ts"; +import { ENV_SERVER_ID, ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.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 ExperimentalMemoryServer { +export interface ExperimentalServer { readonly serverId: string; + readonly sessionDir: string; readonly socketPath: string; readonly server: PiServer; readonly workerPids: ReadonlyMap; @@ -35,50 +37,54 @@ export type ExperimentalClientResult = } | { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string }; -export interface StartExperimentalMemoryServerOptions { - /** Directory for server-addressed Unix sockets. Defaults to a short, private per-user runtime directory. */ +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; } -export interface StartExperimentalServerGenerationOptions { - /** Persistent profile directory. Defaults to ~/.pi/server. */ +export interface StartExperimentalCoordinatedServerOptions { + /** 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; + /** 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; } 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; } -/** Start the temporary in-memory list-and-attach server composition. */ -export async function startExperimentalMemoryServer( - options: StartExperimentalMemoryServerOptions = {}, -): Promise { - const serverId = options.serverId ?? randomUUID(); - const repo = new MemorySessionRepo(); - for (const id of DEMO_SESSION_IDS) { - const session = await repo.create({ id }); - await session.close(); - } +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 sessionDir = resolveExperimentalSessionDirectory(options.sessionDir); const workerPids = new Map(); - const host: PiServerHost = { - sessions: repo, - createHarness: async (session) => { - const sessionId = session.metadata.id; - const worker = await startExperimentalSessionWorker(sessionId); - 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; - } + const controller: ExperimentalWorkerController = { + workerPids, + trackedSessions: [], + createHarness: async (metadata) => { + const sessionId = metadata.id; + const worker = await startExperimentalSessionWorker(metadata, { + sessionDir, + controlDirectory: options.directory, + }); workerPids.set(sessionId, worker.pid); return { terminated: worker.terminated.then((error) => { @@ -95,17 +101,44 @@ export async function startExperimentalMemoryServer( }; }, }; + 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(); - 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()]); + 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"); @@ -115,10 +148,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"); } @@ -127,73 +160,106 @@ export async function startExperimentalMemoryServer( ); return { serverId, + 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 = {}, -): Promise { - const directory = options.directory ?? join(homedir(), ".pi", "server"); - const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory(); - const { serverId, release } = await acquireExperimentalServerProfile(directory); - let runtime: ExperimentalMemoryServer; +/** Start a replaceable experimental server behind the stable coordinator endpoint. */ +export async function startExperimentalCoordinatedServer( + options: StartExperimentalCoordinatedServerOptions = {}, +): Promise { + const directory = resolveExperimentalServerDirectory(options.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; + let workers: CoordinatedSessionWorkers | undefined; + let released = false; try { - await ensurePrivateSocketDirectory(socketDirectory); - const socketPath = getUnixSocketPath(serverId, socketDirectory); - await drainExistingGeneration(serverId, socketPath); - runtime = await startExperimentalMemoryServer({ - directory, - path: socketPath, - serverId, - }); - } catch (error) { - try { - await release(); - } catch (releaseError) { - throw new AggregateError([error, releaseError], "Server generation failed and launcher lock release failed"); - } - throw error; - } + await ensurePrivateServerDirectory(directory); + const socketPath = getUnixSocketPath(serverId, directory); + const controlPath = join(directory, `control-${serverId}.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); + 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. */ @@ -201,10 +267,10 @@ 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() }); + : await discoverUnixServers({ directory: resolveExperimentalServerDirectory(options.directory) }); const discovered: { route: UnixServerRoute; sessionIds: string[] }[] = []; for (const route of routes) { @@ -259,45 +325,3 @@ 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"); - } - 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); -} - -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/server-directory.ts b/packages/coding-agent/src/cli/experimental/server-directory.ts new file mode 100644 index 000000000..48ab1688a --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/server-directory.ts @@ -0,0 +1,22 @@ +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 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")); +} + +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/server-profile.ts b/packages/coding-agent/src/cli/experimental/server-profile.ts index 9b5379042..60ba00a28 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/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 cb63b7241..7078bd93b 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker-process.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker-process.ts @@ -1,56 +1,274 @@ -import { type AgentHarness, MemorySessionRepo } from "@earendil-works/pi-agent-core"; -import { isDemoSessionId } from "./demo-sessions.ts"; +import { createConnection, type Socket } from "node:net"; +import { isAbsolute } from "node:path"; +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 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 control protocol. Real Harness operations will require a -// 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(); }); }); } -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 ?? ""}`); +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 (!Check(WorkerMetadataSchema, parsed) || !isAbsolute(parsed.cwd) || !isAbsolute(parsed.path)) { + throw new Error("Session worker received invalid session metadata"); + } + return parsed; +} - // 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; + releaseOwnership: () => Promise; +}): 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); + } + 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"); +} + +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(args[1]); + 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; + 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, releaseOwnership }); + } 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, releaseOwnership }); return closing; }; - - process.on("message", (message: SessionWorkerCommand) => { - if (message?.type !== "shutdown") return; + const closeAndExit = (): void => { void close().then( () => process.exit(0), (error: unknown) => { @@ -58,16 +276,52 @@ 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 }); + 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 { + ready = true; + await control.send({ type: "worker_ready", token, sessionKey, sessionId, pid: process.pid, metadata }); + } 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) => { - const message = error instanceof Error ? error.message : String(error); - await send({ type: "failed", message }).catch(() => {}); - process.exit(1); -}); +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; + } +} diff --git a/packages/coding-agent/src/cli/experimental/session-worker.ts b/packages/coding-agent/src/cli/experimental/session-worker.ts index a7078f44c..33f604072 100644 --- a/packages/coding-agent/src/cli/experimental/session-worker.ts +++ b/packages/coding-agent/src/cli/experimental/session-worker.ts @@ -1,9 +1,35 @@ -import { type ChildProcess, fork } 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 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"; -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; @@ -13,32 +39,56 @@ export interface ExperimentalSessionWorker { } export interface StartExperimentalSessionWorkerOptions { + readonly sessionDir: string; + readonly controlDirectory?: string; readonly startupTimeoutMs?: number; readonly shutdownTimeoutMs?: number; readonly workerUrl?: URL; } export async function startExperimentalSessionWorker( - sessionId: string, - options: StartExperimentalSessionWorkerOptions = {}, + metadata: JsonlSessionMetadata, + options: StartExperimentalSessionWorkerOptions, ): Promise { 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], { - execArgv: workerExecArgv(workerUrl), - stdio: ["ignore", "ignore", "inherit", "ipc"], - }); + if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute"); + const token = randomUUID(); + const controlId = randomUUID(); + 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 { + child = spawnInternalProcess("session-worker", [options.sessionDir, JSON.stringify(metadata)], { + entryUrl: options.workerUrl, + env: { + [SESSION_WORKER_CONTROL_ADDRESS_ENV]: controlAddress, + [SESSION_WORKER_CONTROL_TOKEN_ENV]: token, + [SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(metadata.path).toString("base64url"), + }, + }); + } 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; } } @@ -50,38 +100,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 { @@ -91,69 +266,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 9fe763b3f..dddff3b0e 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,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 startExperimentalServerGeneration(); + 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 ed925813f..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,26 @@ 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, + 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 +159,13 @@ 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"], + "--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..16866a610 100644 --- a/packages/coding-agent/test/experimental-cli-resolution.test.ts +++ b/packages/coding-agent/test/experimental-cli-resolution.test.ts @@ -80,6 +80,26 @@ describe("experimental CLI command composition", () => { }); }); + test("passes server options to the command action", async () => { + const runServer = 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), + }, + ); + + 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) => { const context = { runPi: vi.fn(() => undefined), 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-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index d243a8f43..61dcf035b 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,20 +1,32 @@ -import { chmod, lstat, mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; +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"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - type ExperimentalMemoryServer, + type ExperimentalServer, runExperimentalClient, - startExperimentalMemoryServer, - startExperimentalServerGeneration, + startExperimentalCoordinatedServer, + startExperimentalServer, } 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 +34,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,6 +54,81 @@ describe("experimental memory server composition", () => { expect((await lstat(directory)).mode & 0o777).toBe(0o750); }); + test("resolves the configured session directory", async () => { + const directory = await mkdtemp(join("/tmp", "pes-")); + directories.add(directory); + const runtime = await startExperimentalServer({ directory, sessionDir: "relative/sessions" }); + servers.add(runtime); + + expect(runtime.sessionDir).toBe(resolve("relative/sessions")); + expect(runtime.workerPids.size).toBe(0); + }); + + 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.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-${runtime.serverId}.sock`)); + expect(publicSocket.isSocket()).toBe(true); + 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" }], + }); + }); + + 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(); @@ -109,66 +197,105 @@ describe("experimental memory 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 }); + 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 }); + 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 }), + startExperimentalCoordinatedServer({ 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 () => { - const firstDirectory = await mkdtemp(join("/tmp", "per-")); - const otherDirectory = await mkdtemp(join("/tmp", "per-")); - directories.add(firstDirectory); - directories.add(otherDirectory); - const first = await startExperimentalServerGeneration({ - directory: firstDirectory, - socketDirectory: firstDirectory, - }); - const other = await startExperimentalServerGeneration({ - directory: otherDirectory, - socketDirectory: otherDirectory, - }); + 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 }); 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({ - directory: firstDirectory, - socketDirectory: firstDirectory, + const client = await PiClient.connect({ + serverId: first.serverId, + transportFactory: createUnixTransportFactory({ path: first.socketPath }), }); + await client.listSessions(); + + const replacement = await startExperimentalCoordinatedServer({ 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-")); + directories.add(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 }); servers.add(replacement); 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", @@ -178,15 +305,42 @@ describe("experimental memory 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 }); + servers.add(first); + await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }); + const workerPid = first.workerPids.get("demo-1"); + + const replacement = await startExperimentalCoordinatedServer({ + 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 () => { 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); @@ -197,8 +351,30 @@ 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" }); + }); }); +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-profile.test.ts b/packages/coding-agent/test/experimental-server-profile.test.ts index d53107a2e..4ff4a7bcf 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, 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"; 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,26 @@ 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); + expect((await readdir(directory)).sort()).toEqual([ + `launcher-${FIRST_SERVER_ID}.lock`, + `launcher-${SECOND_SERVER_ID}.lock`, + ]); + 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 +67,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 32e918fe7..63af4f734 100644 --- a/packages/coding-agent/test/experimental-server-replacement.test.ts +++ b/packages/coding-agent/test/experimental-server-replacement.test.ts @@ -1,11 +1,11 @@ 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"; 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 +17,16 @@ interface RunningCli { readonly errors: () => string; } -async function startServer(home: string): Promise { - const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], { +async function startServer(home: string, agentDir: string, serverDir: string): Promise { + const child = spawn(process.execPath, [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", + PI_SERVER_DIR: serverDir, + }, stdio: ["ignore", "pipe", "pipe"], }); processes.add(child); @@ -91,26 +97,30 @@ afterEach(async () => { }); describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => { - test("a second CLI generation starts clean and accepts explicit reattachment", async () => { - const root = await mkdtemp(join(tmpdir(), "pcs-")); + test("a second CLI server takes over the stable coordinator socket", async () => { + const root = await mkdtemp(join("/tmp", "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"); + const serverDir = join(root, "server"); + await configureExperimentalWorkerModel(agentDir); + await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]); + 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-${firstIdentity[1]}.sock`))).isSocket()).toBe(true); await runExperimentalClient({ command: "client", sessionId: "demo-1", connect: { transport: "unix", path: firstSocket[1]! }, }); - const replacement = await startServer(home); + 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-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-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 b065665cc..c64b35274 100644 --- a/packages/coding-agent/test/experimental-session-worker.test.ts +++ b/packages/coding-agent/test/experimental-session-worker.test.ts @@ -1,34 +1,86 @@ -import { afterEach, describe, expect, test } from "vitest"; +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"; +import { afterEach, beforeEach, 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(); +let serverDir: string; + +beforeEach(async () => { + 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, + 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", { workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker(testMetadata("ready"), { + sessionDir, + workerUrl: fixtureUrl, + }); workers.add(worker); 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); 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(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, }), @@ -40,11 +92,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(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, }); @@ -55,7 +110,10 @@ describe("experimental session worker controller", () => { }); test("reports an unexpected exit after readiness", async () => { - const worker = await startExperimentalSessionWorker("ready", { workerUrl: fixtureUrl }); + const worker = await startExperimentalSessionWorker(testMetadata("ready"), { + sessionDir, + workerUrl: fixtureUrl, + }); workers.add(worker); process.kill(worker.pid, "SIGKILL"); @@ -64,6 +122,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/coding-agent/test/fixtures/session-worker-fixture.ts b/packages/coding-agent/test/fixtures/session-worker-fixture.ts index 77eba715e..0503ba8de 100644 --- a/packages/coding-agent/test/fixtures/session-worker-fixture.ts +++ b/packages/coding-agent/test/fixtures/session-worker-fixture.ts @@ -1,23 +1,52 @@ +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 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; +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: mode, 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: mode, pid: process.pid }); - process.on("message", () => {}); + send({ type: "worker_ready", sessionId, pid: process.pid }); + socket.resume(); } else { - process.send?.({ type: "ready", sessionId: mode === undefined ? "missing" : `${mode}-different`, pid: process.pid }); + 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 81529dfd5..2e06962bf 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -63,36 +63,16 @@ 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"), 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/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 5ef459127..b4dae2008 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -5,36 +5,57 @@ 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. - -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. +- `attach` finds the requested metadata, passes it to the host, and retains the returned Harness handle in the server. 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 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`. -`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/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 1645a044f..93815d13c 100644 --- a/packages/server/src/hosted-harness-manager.ts +++ b/packages/server/src/hosted-harness-manager.ts @@ -1,39 +1,54 @@ +import type { SessionMetadata } from "@earendil-works/pi-agent-core"; import { createRpcDispatcher, + type SessionMetadata as ProtocolSessionMetadata, ProtocolValidationError, ServiceRpc, 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 {} +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); @@ -91,21 +106,11 @@ 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 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 { await harness.close(); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 328ea56a4..155072386 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, @@ -12,7 +13,6 @@ import { ProtocolValidationError, type RequestEnvelope, type ResponseEnvelope, - type ServerControlRpcResult, type ServerHello, type ServerHelloError, type ServerMessage, @@ -23,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"; @@ -32,7 +32,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 +42,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 +51,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; @@ -253,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, @@ -279,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/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/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/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..f325a8e8d 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 } 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: { list(): Promise }; + createHarness(metadata: TMetadata): Promise; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index 4dc517855..6ec24834b 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); @@ -136,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"); @@ -170,118 +245,16 @@ 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 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 +271,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 +286,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 +299,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(); + }); }); 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" }); 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 3d6a8df7a..7592113ea 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -9,9 +9,9 @@ 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)), 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)), @@ -32,9 +32,9 @@ 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 }, { 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 },