mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge branch 'dev' of https://github.com/earendil-works/pi into dev
This commit is contained in:
commit
d87b2b5c21
53 changed files with 2698 additions and 944 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<unknown> {
|
||||
const id = "control-1";
|
||||
const { promise, resolve, reject } = createPromiseResolvers<unknown>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider";
|
||||
import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat";
|
||||
|
||||
setBedrockProviderModule(bedrockProviderModule);
|
||||
9
packages/coding-agent/src/bun/runtime-setup.ts
Normal file
9
packages/coding-agent/src/bun/runtime-setup.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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<ServerCommand, ServerCommandContext>("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<ServerCommand, ServerCommandContext>("s
|
|||
command: "server",
|
||||
...(auth === undefined ? {} : { auth }),
|
||||
...(listen.length === 0 ? {} : { listen }),
|
||||
...(serverId === undefined ? {} : { serverId }),
|
||||
...(sessionDir === undefined ? {} : { sessionDir }),
|
||||
},
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof WorkerMessageSchema>;
|
||||
|
||||
interface WorkerRecord {
|
||||
readonly peerId: string;
|
||||
readonly metadata: JsonlSessionMetadata;
|
||||
readonly pid: number;
|
||||
readonly terminated: Promise<Error | undefined>;
|
||||
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<WorkerRecord>;
|
||||
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<string, number>();
|
||||
readonly #coordinator: CoordinatorServer;
|
||||
readonly #sessionDir: string;
|
||||
readonly #workersBySession = new Map<string, WorkerRecord>();
|
||||
readonly #workersByPeer = new Map<string, WorkerRecord>();
|
||||
readonly #pending = new Map<string, PendingLaunch>();
|
||||
readonly #removeListener: () => void;
|
||||
#discoveryPeers?: Set<string>;
|
||||
#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<string>): Promise<void> {
|
||||
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<void>((resolve) => {
|
||||
this.#resolveDiscovery = resolve;
|
||||
});
|
||||
await this.#coordinator.broadcast({ type: "discover_workers" });
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
discovered,
|
||||
new Promise<void>((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<HostedHarnessHandle> {
|
||||
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<void> {
|
||||
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<void>((resolve) => pending.child.once("exit", () => resolve()));
|
||||
}),
|
||||
]).then(() => undefined);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timedOut = new Promise<boolean>((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<WorkerRecord> {
|
||||
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<WorkerRecord>((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<WorkerMessage, { type: "worker_ready" }>): 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<Error | undefined>((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;
|
||||
}
|
||||
}
|
||||
255
packages/coding-agent/src/cli/experimental/coordinator-client.ts
Normal file
255
packages/coding-agent/src/cli/experimental/coordinator-client.ts
Normal file
|
|
@ -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<typeof CoordinatorMessageSchema>;
|
||||
|
||||
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<void>;
|
||||
readonly peerIds = new Set<string>();
|
||||
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<void> {
|
||||
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<void>((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<void> {
|
||||
return this.#write({ type: "send", to: peerId, payload });
|
||||
}
|
||||
|
||||
broadcast(payload: unknown): Promise<void> {
|
||||
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<void> {
|
||||
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<CoordinatorStartupLease> {
|
||||
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<void>((resolve) => setTimeout(resolve, COORDINATOR_RETRY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
function connectSocket(path: string): Promise<Socket> {
|
||||
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<Socket | undefined> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(`${JSON.stringify(message)}\n`, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<string, RoutedPeer>();
|
||||
const controlConnections = new Set<Socket>();
|
||||
const publicConnections = new Map<Socket, Socket>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function restrictSocket(path: string): Promise<void> {
|
||||
if (process.platform !== "win32") await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
async function removeStaleSocket(path: string): Promise<void> {
|
||||
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<boolean>((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<void> {
|
||||
if (process.platform === "win32") return;
|
||||
await unlink(path).catch((error: unknown) => {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
|
|
@ -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<ExperimentalMemoryServer> {
|
||||
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<string, number>;
|
||||
readonly trackedSessions: readonly JsonlSessionMetadata[];
|
||||
createHarness(metadata: JsonlSessionMetadata): Promise<HostedHarnessHandle>;
|
||||
}
|
||||
|
||||
/** Start the experimental durable list-and-attach server composition. */
|
||||
export async function startExperimentalServer(
|
||||
options: StartExperimentalServerOptions = {},
|
||||
): Promise<ExperimentalServer> {
|
||||
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
|
||||
const workerPids = new Map<string, number>();
|
||||
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<ExperimentalServer> {
|
||||
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<JsonlSessionMetadata> = {
|
||||
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<void> => {
|
||||
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<void> | 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<ExperimentalMemoryServer> {
|
||||
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<ExperimentalServer> {
|
||||
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<void> | 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<void> {
|
||||
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<ExperimentalClientResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown>();
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
if ("code" in current && current.code === code) return true;
|
||||
current = current.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
/** Lock one experimental server profile and load or create its stable identity. */
|
||||
export async function acquireExperimentalServerProfile(directory: string): Promise<ExperimentalServerProfile> {
|
||||
/** Lock one logical server ID in a shared experimental server directory. */
|
||||
export async function acquireExperimentalServerProfile(
|
||||
directory: string,
|
||||
requestedServerId?: string,
|
||||
): Promise<ExperimentalServerProfile> {
|
||||
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<ServerId> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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<void> {
|
||||
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<typeof CoordinatorInputSchema>;
|
||||
type WorkerCommand = Static<typeof WorkerCommandSchema>;
|
||||
|
||||
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<unknown>;
|
||||
readonly socket: Socket;
|
||||
send(event: SessionWorkerControlEvent): Promise<void>;
|
||||
}
|
||||
|
||||
let failureControl: WorkerControl | undefined;
|
||||
|
||||
async function connectControl(): Promise<WorkerControl> {
|
||||
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<void>((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<void> {
|
||||
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<unknown> {
|
||||
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<unknown>((resolve) => waiters.push(resolve)));
|
||||
return { done: false as const, value };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(`${JSON.stringify(message)}\n`, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
// 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<AgentHarness, "close"> = { close: () => session.close() };
|
||||
async function closeResources(resources: {
|
||||
harness?: AgentHarnessInstance;
|
||||
session?: Session<JsonlSessionMetadata>;
|
||||
repo: JsonlSessionRepo;
|
||||
executionEnv: NodeExecutionEnv;
|
||||
releaseOwnership: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
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<JsonlSessionMetadata> | 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<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
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<void> {
|
|||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof WorkerControlInputSchema>;
|
||||
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<ExperimentalSessionWorker> {
|
||||
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<Socket>;
|
||||
readonly events: AsyncIterable<WorkerControllerEvent>;
|
||||
send(message: SessionWorkerCommand): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
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<WorkerControlServer> {
|
||||
let accepted: Socket | undefined;
|
||||
let resolveConnected!: (socket: Socket) => void;
|
||||
const connected = new Promise<Socket>((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<void> | 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<void>((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<Extract<SessionWorkerEvent, { type: "ready" }>> {
|
||||
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<never>((_, 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<Error | undefined>;
|
||||
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<void>;
|
||||
#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<void> {
|
||||
|
|
@ -91,69 +266,62 @@ class ChildSessionWorker implements ExperimentalSessionWorker {
|
|||
}
|
||||
|
||||
async #close(): Promise<void> {
|
||||
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<Extract<SessionWorkerEvent, { type: "ready" }>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
socket?.destroy();
|
||||
if (server.listening) await new Promise<void>((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 });
|
||||
|
|
|
|||
|
|
@ -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<void>): Promise<void> {
|
|||
async function runExperimentalServerCommand(command: ServerCommand): Promise<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<ChildProcess>();
|
||||
const directories = new Set<string>();
|
||||
|
||||
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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(path);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
|
@ -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<ExperimentalMemoryServer>();
|
||||
const servers = new Set<ExperimentalServer>();
|
||||
const directories = new Set<string>();
|
||||
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<false>((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<false>((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<boolean> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
async function makeDirectory(): Promise<string> {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ChildProcess>();
|
||||
|
|
@ -17,10 +17,16 @@ interface RunningCli {
|
|||
readonly errors: () => string;
|
||||
}
|
||||
|
||||
async function startServer(home: string): Promise<RunningCli> {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], {
|
||||
async function startServer(home: string, agentDir: string, serverDir: string): Promise<RunningCli> {
|
||||
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})/,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
32
packages/coding-agent/test/experimental-session-support.ts
Normal file
32
packages/coding-agent/test/experimental-session-support.ts
Normal file
|
|
@ -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<JsonlSessionMetadata[]> {
|
||||
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<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "auth.json"), JSON.stringify({ anthropic: { type: "api_key", key: "test-key" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<ExperimentalSessionWorker>();
|
||||
const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url);
|
||||
const sessionDir = "/tmp/pi-session-worker-tests";
|
||||
const directories = new Set<string>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void>((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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -63,36 +63,16 @@ export type ServiceRpcResultUnion = RpcResultUnion<ServiceRpcManifest>;
|
|||
export const ServiceRpcCallSchema = Type.Unsafe<ServiceRpcCall>(createRpcCallSchema(ServiceRpc));
|
||||
export const ServiceRpcResultSchema = Type.Unsafe<ServiceRpcResultUnion>(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<ServerControlRpcManifest>;
|
||||
export type ServerControlRpcArgs<TMethod extends ServerControlRpcMethod> = RpcArgs<ServerControlRpcManifest, TMethod>;
|
||||
export type ServerControlRpcResult<TMethod extends ServerControlRpcMethod> = RpcResult<
|
||||
ServerControlRpcManifest,
|
||||
TMethod
|
||||
>;
|
||||
export type ServerControlRpcCall = RpcCall<ServerControlRpcManifest>;
|
||||
export type ServerControlRpcResultUnion = RpcResultUnion<ServerControlRpcManifest>;
|
||||
export const ServerControlRpcCallSchema = Type.Unsafe<ServerControlRpcCall>(createRpcCallSchema(ServerControlRpc));
|
||||
export const ServerControlRpcResultSchema = Type.Unsafe<ServerControlRpcResultUnion>(
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<HostedHarnessHandle>,
|
||||
) {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { ProtocolErrorCode } from "@earendil-works/pi-protocol";
|
||||
|
||||
type PiServerOperationErrorCode = Extract<ProtocolErrorCode, "wrong_server" | "session_not_found" | "server_draining">;
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<TMetadata extends SessionMetadata> {
|
||||
host: PiServerHost<TMetadata>;
|
||||
isClosing: () => boolean;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class HostedHarnessManager {
|
||||
private readonly options: HostedHarnessManagerOptions;
|
||||
export class HostedHarnessManager<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
private readonly options: HostedHarnessManagerOptions<TMetadata>;
|
||||
private readonly hostedSessions = new Map<string, HostedSession>();
|
||||
private readonly openingSessions = new Map<string, Promise<HostedSession>>();
|
||||
private closePromise?: Promise<void>;
|
||||
private readonly dispatchRpc: (call: ServiceRpcCall, context: undefined) => Promise<ServiceRpcResultUnion>;
|
||||
|
||||
constructor(options: HostedHarnessManagerOptions) {
|
||||
constructor(options: HostedHarnessManagerOptions<TMetadata>) {
|
||||
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<HostedSession> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
readonly serverId: string;
|
||||
/** Resolves after shutdown, or rejects when listener or hosted-Harness cleanup fails. */
|
||||
readonly closed: Promise<void>;
|
||||
|
|
@ -42,7 +42,7 @@ export class PiServer {
|
|||
private readonly handshakeTimeoutMs: number;
|
||||
private readonly onError: ((error: Error) => void) | undefined;
|
||||
private readonly connections = new Set<ConnectionState>();
|
||||
private readonly sessions: HostedHarnessManager;
|
||||
private readonly sessions: HostedHarnessManager<TMetadata>;
|
||||
private closing = false;
|
||||
private closePromise?: Promise<void>;
|
||||
private closedSettled = false;
|
||||
|
|
@ -51,7 +51,7 @@ export class PiServer {
|
|||
private startPromise?: Promise<this>;
|
||||
private started = false;
|
||||
|
||||
constructor(host: PiServerHost, options: PiServerOptions) {
|
||||
constructor(host: PiServerHost<TMetadata>, 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<void> {
|
||||
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<ServerControlRpcResult<"drain">> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ interface ListDelay {
|
|||
export class TestServerHost implements PiServerHost {
|
||||
readonly repo = new MemorySessionRepo({ now: () => 1 });
|
||||
readonly harnesses = new Map<string, TestHarness[]>();
|
||||
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<HostedHarnessHandle> {
|
||||
if (this.failNextHarness) {
|
||||
const error = this.failNextHarness;
|
||||
this.failNextHarness = undefined;
|
||||
async createHarness(metadata: SessionMetadata): Promise<HostedHarnessHandle> {
|
||||
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<SessionMetadata> {
|
||||
|
|
@ -131,9 +133,9 @@ export class TestServerHost implements PiServerHost {
|
|||
return delay;
|
||||
}
|
||||
|
||||
gateNextOpen(): OpenGate {
|
||||
gateNextCreateHarness(): OpenGate {
|
||||
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
|
||||
this.nextOpenGate = gate;
|
||||
this.nextCreateHarnessGate = gate;
|
||||
return gate;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
@ -315,7 +316,7 @@ async function removeStaleSocket(path: string): Promise<void> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<TMetadata extends SessionMetadata>(
|
||||
host: PiServerHost<TMetadata>,
|
||||
options: UnixServerOptions,
|
||||
): PiServer<TMetadata> {
|
||||
const listener = createUnixListener({
|
||||
path: options.path,
|
||||
mode: options.mode,
|
||||
|
|
|
|||
|
|
@ -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<AgentHarness, "close"> {
|
|||
}
|
||||
|
||||
/** Host capabilities used directly by the list and attach control-plane operations. */
|
||||
export interface PiServerHost {
|
||||
readonly sessions: Pick<SessionRepo, "list" | "open">;
|
||||
createHarness(session: Session): Promise<HostedHarnessHandle>;
|
||||
export interface PiServerHost<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
readonly sessions: { list(): Promise<TMetadata[]> };
|
||||
createHarness(metadata: TMetadata): Promise<HostedHarnessHandle>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PiServer>();
|
||||
|
||||
|
|
@ -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<BackendMetadata> = {
|
||||
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<BackendMetadata> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue