diff --git a/packages/agent/docs/work-packages/02-atomic-run-acceptance.md b/packages/agent/docs/work-packages/02-atomic-run-acceptance.md index 85bd9c780..862de5569 100644 --- a/packages/agent/docs/work-packages/02-atomic-run-acceptance.md +++ b/packages/agent/docs/work-packages/02-atomic-run-acceptance.md @@ -351,7 +351,7 @@ Modify session types: - add the already-normative `ToolCall.outcome_ready` vocabulary without producers; - document `sourceIndex` as full assistant-content index; - add callback-scoped `scanBranch` to `SessionReader`; inheritance by `SessionMutator` and `Session` is intentional; -- implement it in `StorageBackedSession` and its mutator, `MemorySessionFacade`, `RemoteSession`, and `RemoteSessionMutator`, including the remote mutation RPC dispatcher. +- implement it in `StorageBackedSession` and its mutator and `MemorySessionFacade`; Session mutation authority remains process-local and has no remote Session facade. ### Event publication diff --git a/packages/agent/docs/work-packages/04-mutation-publication.md b/packages/agent/docs/work-packages/04-mutation-publication.md index 79cdec6a2..19179bfda 100644 --- a/packages/agent/docs/work-packages/04-mutation-publication.md +++ b/packages/agent/docs/work-packages/04-mutation-publication.md @@ -226,7 +226,7 @@ Modify: - local Session implementations and tests required by the `createLane` signature; - direct event primitive, acceptance, watch, lane, and harness tests. -Remote/experimental runtime behavior is not a design constraint for WP04. For compile compatibility only, `RemoteSession.createLane` invokes a non-`undefined` callback client-side after its successful create RPC; it does not claim the local commit-continuation boundary. Do not add remote callback transport, protocol machinery, compatibility abstractions, or boundary tests. +Remote/experimental runtime behavior is not a design constraint for WP04. Session mutation authority remains process-local; do not add remote Session callback transport, protocol machinery, compatibility abstractions, or boundary tests. ### Documentation diff --git a/packages/agent/src/harness/session/index.ts b/packages/agent/src/harness/session/index.ts index 1874b5f59..b6dc491e0 100644 --- a/packages/agent/src/harness/session/index.ts +++ b/packages/agent/src/harness/session/index.ts @@ -21,7 +21,6 @@ export { } from "./jsonl/index.ts"; export type { MemorySessionRepoOptions } from "./memory.ts"; export { MemorySessionRepo } from "./memory.ts"; -export { RemoteSession, type RemoteSessionRpc, RemoteSessionTree } from "./remote.ts"; export { SessionInvalidLaneError, SessionInvariantError, diff --git a/packages/agent/src/harness/session/remote.ts b/packages/agent/src/harness/session/remote.ts deleted file mode 100644 index 910c5dbc0..000000000 --- a/packages/agent/src/harness/session/remote.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import type { Context } from "../context.ts"; -import { withoutAbortSignal } from "../context.ts"; -import { - SessionInvalidLaneError, - SessionInvariantError, - SessionLaneExistsError, - SessionPendingAssistantMessageError, - SessionUnknownTargetError, -} from "./session.ts"; -import type { - BranchScan, - CommitResult, - Entry, - EntryQuery, - IdGenerator, - JsonValue, - LaneConfiguration, - Session, - SessionMetadata, - SessionMutator, - SessionStats, - SessionTree, - StorageBranchScan, - Write, -} from "./types.ts"; -import type { ListElement, ListReadOptions, StoredValue, Value, ValueList } from "./values.ts"; - -/** Untyped invocation boundary used by the remote Session facade. */ -export interface RemoteSessionRpc { - invoke(method: string, args: unknown, context: Context): Promise; -} - -interface RemoteSessionOpenResult { - handle: string; - metadata: SessionMetadata; -} - -/** SessionTree facade whose methods execute against one remote Session lane. */ -export class RemoteSessionTree implements SessionTree { - protected readonly rpc: RemoteSessionRpc; - protected readonly handle: string; - protected readonly lane: string; - - constructor(rpc: RemoteSessionRpc, handle: string, lane: string) { - this.rpc = rpc; - this.handle = handle; - this.lane = lane; - } - - async getLeafId(context: Context): Promise { - return expectNullableString(await this.invoke("session.tree.getLeafId", {}, context), "leaf ID"); - } - - async getEntry(id: string, context: Context): Promise { - const result = await this.invoke("session.tree.getEntry", { id }, context); - return result === null ? undefined : (expectObject(result, "entry") as unknown as Entry); - } - - async getStats(context: Context): Promise { - return expectObject( - await this.invoke("session.tree.getStats", {}, context), - "Session stats", - ) as unknown as SessionStats; - } - - async getValue(address: Value, context: Context): Promise | undefined> { - const result = await this.invoke("session.tree.getValue", { address }, context); - return result === null ? undefined : (expectObject(result, "stored value") as unknown as StoredValue); - } - - async scanValues(prefix: Value, context: Context): Promise[]> { - return expectArray( - await this.invoke("session.tree.scanValues", { prefix }, context), - "stored values", - ) as StoredValue[]; - } - - async readList( - address: ValueList, - options: ListReadOptions | undefined, - context: Context, - ): Promise[]> { - return expectArray( - await this.invoke("session.tree.readList", { address, options: options ?? null }, context), - "list elements", - ) as ListElement[]; - } - - async setValue(address: Value, next: NoInfer, context: Context): Promise { - expectNull(await this.invoke("session.tree.setValue", { address, next }, context)); - } - - async deleteValue(address: Value, context: Context): Promise { - expectNull(await this.invoke("session.tree.deleteValue", { address }, context)); - } - - async appendList(address: ValueList, element: NoInfer, context: Context): Promise { - expectNull(await this.invoke("session.tree.appendList", { address, element }, context)); - } - - async deleteList(address: ValueList, context: Context): Promise { - expectNull(await this.invoke("session.tree.deleteList", { address }, context)); - } - - async getName(context: Context): Promise { - const value = expectNullableString(await this.invoke("session.tree.getName", {}, context), "Session name"); - return value === null ? undefined : value; - } - - async setName(name: string | undefined, context: Context): Promise { - expectNull(await this.invoke("session.tree.setName", { name: name ?? null }, context)); - } - - async getLabel(targetId: string, context: Context): Promise { - const value = expectNullableString( - await this.invoke("session.tree.getLabel", { targetId }, context), - "entry label", - ); - return value === null ? undefined : value; - } - - async setLabel(targetId: string, label: string | undefined, context: Context): Promise { - expectNull(await this.invoke("session.tree.setLabel", { targetId, label: label ?? null }, context)); - } - - async findEntries(query: EntryQuery | undefined, context: Context): Promise { - return expectArray( - await this.invoke("session.tree.findEntries", { query: query ?? null }, context), - "entries", - ) as Entry[]; - } - - async findEntry(query: EntryQuery | undefined, context: Context): Promise { - const result = await this.invoke("session.tree.findEntry", { query: query ?? null }, context); - return result === null ? undefined : (expectObject(result, "entry") as unknown as Entry); - } - - async findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise { - return expectArray( - await this.invoke("session.tree.findEntriesOnBranch", { query: query ?? null }, context), - "branch entries", - ) as Entry[]; - } - - async findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise { - const result = await this.invoke("session.tree.findEntryOnBranch", { query: query ?? null }, context); - return result === null ? undefined : (expectObject(result, "entry") as unknown as Entry); - } - - async appendMessage(message: Parameters[0], context: Context): Promise { - return expectString(await this.invoke("session.tree.appendMessage", { message }, context), "entry ID"); - } - - async appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { - return expectString( - await this.invoke( - "session.tree.appendCustomEntry", - { customType, ...(data === undefined ? {} : { data }) }, - context, - ), - "entry ID", - ); - } - - protected async invoke(method: string, args: Record, context: Context): Promise { - try { - return await this.rpc.invoke(method, { handle: this.handle, lane: this.lane, ...args }, context); - } catch (error) { - const code = remoteErrorCode(error); - if (code === "session_pending_message") throw new SessionPendingAssistantMessageError(); - if (code === "session_invariant") throw new SessionInvariantError(remoteErrorMessage(error)); - throw error; - } - } -} - -/** Session facade backed entirely by untyped RPC calls. */ -export class RemoteSession - extends RemoteSessionTree - implements Session -{ - readonly metadata: TMetadata; - readonly idGenerator: IdGenerator = { next: uuidv7 }; - private closed = false; - - constructor(rpc: RemoteSessionRpc, handle: string, metadata: TMetadata) { - super(rpc, handle, "main"); - this.metadata = metadata; - } - - static async open( - rpc: RemoteSessionRpc, - sessionId: string, - context: Context, - ): Promise> { - const result = expectObject(await rpc.invoke("session.open", { sessionId }, context), "remote Session"); - const opened: RemoteSessionOpenResult = { - handle: expectString(result.handle, "remote Session handle"), - metadata: decodeMetadata(result.metadata), - }; - return new RemoteSession(rpc, opened.handle, opened.metadata); - } - - view(lane: string): SessionTree { - this.assertOpen(); - return new RemoteSessionTree(this.rpc, this.handle, lane); - } - - async getEntries(ids: string[], context: Context): Promise> { - const pairs = expectArray(await this.invoke("session.getEntries", { ids }, context), "entry pairs"); - const entries = new Map(); - for (const pair of pairs) { - if (!Array.isArray(pair) || pair.length !== 2) - throw new TypeError("Remote Session returned an invalid entry pair"); - entries.set(expectString(pair[0], "entry ID"), expectObject(pair[1], "entry") as unknown as Entry); - } - return entries; - } - - async getValue(address: Value, context: Context): Promise | undefined> { - const result = await this.invoke("session.getValue", { address }, context); - return result === null ? undefined : (expectObject(result, "stored value") as unknown as StoredValue); - } - - async scanValues(prefix: Value, context: Context): Promise[]> { - return expectArray( - await this.invoke("session.scanValues", { prefix }, context), - "stored values", - ) as StoredValue[]; - } - - async readList( - address: ValueList, - options: ListReadOptions | undefined, - context: Context, - ): Promise[]> { - return expectArray( - await this.invoke("session.readList", { address, options: options ?? null }, context), - "list elements", - ) as ListElement[]; - } - - async scanBranch(query: StorageBranchScan, context: Context): Promise { - return expectArray(await this.invoke("session.scanBranch", { query }, context), "branch entries") as Entry[]; - } - - async mutate( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ): Promise { - this.assertOpen(); - const opened = expectObject( - await this.invoke("session.mutation.begin", { mutationLane: lane }, context), - "remote mutation", - ); - const mutationId = expectString(opened.mutationId, "remote mutation ID"); - const mutator = new RemoteSessionMutator(this.rpc, this.handle, mutationId, lane); - let outcome: { ok: true; value: T } | { ok: false; error: unknown }; - try { - outcome = { ok: true, value: await mutation(mutator, context) }; - } catch (error) { - outcome = { ok: false, error }; - } - await mutator.settle(); - mutator.invalidate(); - let finishFailure: { error: unknown } | undefined; - try { - expectNull( - await this.rpc.invoke( - "session.mutation.finish", - { handle: this.handle, mutationId }, - withoutAbortSignal(context), - ), - ); - } catch (error) { - finishFailure = { error }; - } - if (!outcome.ok) { - if (finishFailure !== undefined) { - throw new AggregateError( - [outcome.error, finishFailure.error], - "Remote Session mutation and cleanup failed", - ); - } - throw outcome.error; - } - if (finishFailure !== undefined) throw finishFailure.error; - return outcome.value; - } - - async createLane( - name: string, - at: string | null, - configuration: LaneConfiguration, - onCommitted: ((context: Context) => void | Promise) | undefined, - context: Context, - ): Promise { - this.assertOpen(); - try { - expectNull(await this.invoke("session.createLane", { name, at, configuration }, context)); - } catch (error) { - const code = remoteErrorCode(error); - if (code === "session_invalid_lane") throw new SessionInvalidLaneError(name, remoteErrorMessage(error)); - if (code === "session_lane_exists") throw new SessionLaneExistsError(name); - if (code === "session_unknown_target" && at !== null) throw new SessionUnknownTargetError(at); - throw error; - } - await onCommitted?.(context); - return this.view(name); - } - - async close(context: Context): Promise { - if (this.closed) return; - this.closed = true; - expectNull(await this.rpc.invoke("session.close", { handle: this.handle }, context)); - } - - private assertOpen(): void { - if (this.closed) throw new Error("Session is closed"); - } -} - -class RemoteSessionMutator implements SessionMutator { - readonly lane: string; - private readonly rpc: RemoteSessionRpc; - private readonly handle: string; - private readonly mutationId: string; - private active = true; - private commitPromise: Promise | undefined; - - constructor(rpc: RemoteSessionRpc, handle: string, mutationId: string, lane: string) { - this.rpc = rpc; - this.handle = handle; - this.mutationId = mutationId; - this.lane = lane; - } - - commit(writes: Write[], context: Context): Promise { - this.assertActive(); - if (this.commitPromise !== undefined) return Promise.reject(new Error("SessionMutator commit already attempted")); - this.commitPromise = this.rpc - .invoke("session.mutation.commit", { handle: this.handle, mutationId: this.mutationId, writes }, context) - .then(decodeCommitResult); - return this.commitPromise; - } - - async getEntries(ids: string[], context: Context): Promise> { - this.assertActive(); - const pairs = expectArray( - await this.rpc.invoke( - "session.mutation.getEntries", - { handle: this.handle, mutationId: this.mutationId, ids }, - context, - ), - "entry pairs", - ); - const entries = new Map(); - for (const pair of pairs) { - if (!Array.isArray(pair) || pair.length !== 2) - throw new TypeError("Remote Session returned an invalid entry pair"); - entries.set(expectString(pair[0], "entry ID"), expectObject(pair[1], "entry") as unknown as Entry); - } - return entries; - } - - async getValue(address: Value, context: Context): Promise | undefined> { - this.assertActive(); - const result = await this.rpc.invoke( - "session.mutation.getValue", - { handle: this.handle, mutationId: this.mutationId, address }, - context, - ); - return result === null ? undefined : (expectObject(result, "stored value") as unknown as StoredValue); - } - - async scanValues(prefix: Value, context: Context): Promise[]> { - this.assertActive(); - return expectArray( - await this.rpc.invoke( - "session.mutation.scanValues", - { handle: this.handle, mutationId: this.mutationId, prefix }, - context, - ), - "stored values", - ) as StoredValue[]; - } - - async readList( - address: ValueList, - options: ListReadOptions | undefined, - context: Context, - ): Promise[]> { - this.assertActive(); - return expectArray( - await this.rpc.invoke( - "session.mutation.readList", - { handle: this.handle, mutationId: this.mutationId, address, options: options ?? null }, - context, - ), - "list elements", - ) as ListElement[]; - } - - async scanBranch(query: StorageBranchScan, context: Context): Promise { - this.assertActive(); - return expectArray( - await this.rpc.invoke( - "session.mutation.scanBranch", - { handle: this.handle, mutationId: this.mutationId, query }, - context, - ), - "branch entries", - ) as Entry[]; - } - - settle(): Promise { - return ( - this.commitPromise?.then( - () => undefined, - () => undefined, - ) ?? Promise.resolve() - ); - } - - invalidate(): void { - this.active = false; - } - - private assertActive(): void { - if (!this.active) throw new Error("SessionMutator cannot be used outside its mutation callback"); - } -} - -function decodeMetadata(value: unknown): SessionMetadata { - const metadata = expectObject(value, "Session metadata"); - return { - id: expectString(metadata.id, "Session ID"), - createdAt: expectNumber(metadata.createdAt, "Session creation time"), - storageVersion: expectNumber(metadata.storageVersion, "Session storage version"), - ...(metadata.cwd === undefined ? {} : { cwd: expectString(metadata.cwd, "Session cwd") }), - ...(metadata.parentSessionId === undefined - ? {} - : { parentSessionId: expectString(metadata.parentSessionId, "parent Session ID") }), - ...(metadata.legacyParentSessionPath === undefined - ? {} - : { legacyParentSessionPath: expectString(metadata.legacyParentSessionPath, "legacy parent Session path") }), - }; -} - -function decodeCommitResult(value: unknown): CommitResult { - const result = expectObject(value, "commit result"); - const seqs = expectArray(result.seqs, "commit sequences").map((seq) => expectNumber(seq, "commit sequence")); - return { - firstSeq: expectNumber(result.firstSeq, "first commit sequence"), - seqs, - timestamp: expectNumber(result.timestamp, "commit timestamp"), - }; -} - -function expectObject(value: unknown, description: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError(`Remote Session returned an invalid ${description}`); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError(`Remote Session returned an invalid ${description}`); - } - return value as Record; -} - -function expectArray(value: unknown, description: string): unknown[] { - if (!Array.isArray(value)) throw new TypeError(`Remote Session returned invalid ${description}`); - return value; -} - -function expectString(value: unknown, description: string): string { - if (typeof value !== "string") throw new TypeError(`Remote Session returned an invalid ${description}`); - return value; -} - -function expectNullableString(value: unknown, description: string): string | null { - if (value === null) return null; - return expectString(value, description); -} - -function expectNumber(value: unknown, description: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new TypeError(`Remote Session returned an invalid ${description}`); - } - return value; -} - -function expectNull(value: unknown): void { - if (value !== null) throw new TypeError("Remote Session operation returned an invalid result"); -} - -function remoteErrorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" - ? error.code - : undefined; -} - -function remoteErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/client/README.md b/packages/client/README.md index 256a8e9bb..412f783c9 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -19,8 +19,8 @@ const client = await PiClient.connect({ serverId: "01234567-89ab-4def-8123-456789abcdef", transportFactory, }); -const session = await client.createSession({ cwd: "/workspace" }); -const attachment = await client.attachSession(session.id); +const session = await client.createSession({}); +const attachment = await client.attachSession(session.sessionId); const watch = await client.watchSession(attachment.sessionId); await watch.start((event) => { if (event.type === "message_update" && event.frame.type === "text_delta") { @@ -31,13 +31,13 @@ const result = await client.promptSession(attachment.sessionId, "Summarize this await watch.dispose(); ``` -The client verifies that the physical endpoint reports the expected logical `serverId`. Every Session request carries that ID again so the final server can reject misdelivery. +The client verifies that the physical endpoint reports the expected logical `serverId`. Server-wide requests carry that ID, and every Session request carries the full live target `{ serverId, sessionId, attachmentId }`. The combined durable address prevents cross-server or cross-session misrouting; the server-generated attachment ID rejects delayed frames after switching or reattaching. -`createSession()` creates durable Session metadata without attaching or opening a Harness; pass `id` to request an exact ID or omit it to generate one. `attachSession()` returns only `{ sessionId }`; the attachment is exclusive to that client connection. `promptSession()` accepts the protocol's serializable Harness prompt overloads and returns a structural `RunResult`. +`createSession()` creates a presentation-safe Session summary without attaching or opening a worker; pass `id` to request an exact ID or omit it to generate one. The server derives private fields such as the working directory from its authenticated workspace context. `attachSession()` returns `{ sessionId, attachmentId }` and updates the target used by subsequent Session operations. Multiple clients may attach to one Session. `promptSession()` accepts the protocol's serializable Harness prompt overloads and returns a structural `RunResult`. `watchSession()` creates a main-lane watch and returns its authoritative snapshot without starting event delivery. Install the listener with `await watch.start(listener)`; this then flushes events buffered after the snapshot and continues with live events while `promptSession()` is pending. `watch.dispose()` stops server delivery and waits for already-received listener work. A disconnected watch is stale and cannot be reused after reconnection. -On disconnect or disposal, pending prompts reject locally, but accepted work may still complete remotely before the attachment is released. The client never reconnects or replays requests automatically. After disconnection, call `reconnect()` and explicitly repeat only operations known to be safe. +On disconnect or disposal, pending prompts reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach again, and explicitly repeat only operations known to be safe. 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. diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 9ee234e8c..3593a551d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -2,6 +2,7 @@ import { createRpcClient, type EventEnvelope, encodeClientMessage, + encodeServiceRpcCall, isServerId, type LaneEvent, type PromptArguments, @@ -10,12 +11,15 @@ import { type ProtocolRpcCall, ProtocolValidationError, type ResponseEnvelope, + type RpcTarget, type ServerHello, ServiceRpc, type ServiceRpcCall, type ServiceRpcResult, + type SessionAddress, type SessionCreateOptions, - type SessionMetadata, + type SessionSummary, + type SessionTarget, } from "@earendil-works/pi-protocol"; import { Connection } from "./connection.ts"; import { PiClientDisposedError, PiDisconnectedError, PiServerError, toError } from "./errors.ts"; @@ -42,6 +46,7 @@ export class PiClient { readonly #rpc: ReturnType>; #requestSequence = 0; #hello: ServerHello | undefined; + #attachment: SessionTarget | undefined; #disposed = false; #disposePromise: Promise | undefined; @@ -62,7 +67,7 @@ export class PiClient { }); this.#rpc = createRpcClient( ServiceRpc, - (call) => this.#request(call), + (call) => this.#request(this.#targetForCall(call), encodeServiceRpcCall(call)), (message) => new ProtocolValidationError(message), ); } @@ -83,6 +88,10 @@ export class PiClient { return this.#hello; } + get attachment(): SessionTarget | undefined { + return this.#attachment; + } + static async connect(options: PiClientOptions): Promise { const client = new PiClient(options); try { @@ -114,13 +123,12 @@ export class PiClient { return () => this.#connectionStateListeners.delete(listener); } - /** Invoke an untyped RPC method. Domain-specific facades own argument and result typing. */ - invoke(method: string, args: unknown, signal?: AbortSignal): Promise { - if (method.length === 0) return Promise.reject(new TypeError("RPC method must not be empty")); - return this.#request({ method, args }, signal); + /** Invoke one low-level protocol call against an explicit routed target. */ + request(target: RpcTarget, call: ProtocolRpcCall, signal?: AbortSignal): Promise { + return this.#request(target, call, signal); } - listSessions(): Promise { + listSessions(): Promise { return this.#rpc.list(); } @@ -128,8 +136,14 @@ export class PiClient { return this.#rpc.create(options); } - attachSession(sessionId: string): Promise> { - return this.#rpc.attach(sessionId); + async attachSession(session: string | SessionAddress): Promise> { + const sessionId = typeof session === "string" ? session : session.sessionId; + if (typeof session !== "string" && session.serverId !== this.#options.serverId) { + throw new PiServerError({ code: "wrong_server", message: "Session belongs to another server" }); + } + const attached = await this.#rpc.attach(sessionId); + this.#attachment = { serverId: this.#options.serverId, ...attached }; + return attached; } promptSession(sessionId: string, text: string, images?: PromptImage[]): Promise>; @@ -139,16 +153,18 @@ export class PiClient { message: string | PromptMessage | PromptMessage[], images?: PromptImage[], ): Promise> { + this.#requireSessionTarget(sessionId); if (typeof message === "string") { const prompt: PromptArguments = images === undefined ? [message] : [message, images]; - return this.#rpc.prompt(sessionId, prompt); + return this.#rpc.prompt(prompt); } - if (Array.isArray(message)) return this.#rpc.prompt(sessionId, [message]); - return this.#rpc.prompt(sessionId, [message]); + if (Array.isArray(message)) return this.#rpc.prompt([message]); + return this.#rpc.prompt([message]); } async watchSession(sessionId: string): Promise { - const { watchId, snapshot } = await this.#rpc.watch(sessionId); + this.#requireSessionTarget(sessionId); + const { watchId, snapshot } = await this.#rpc.watch(); const connection = this.#hello; let state: "ready" | "starting" | "started" | "disposed" = "ready"; return { @@ -164,7 +180,8 @@ export class PiClient { state = "starting"; this.#watchListeners.set(watchId, { listener, deliveryTail: Promise.resolve() }); try { - await this.#rpc.startWatch(sessionId, watchId); + this.#requireSessionTarget(sessionId); + await this.#rpc.startWatch(watchId); if (state === "starting") state = "started"; } catch (error) { this.#watchListeners.delete(watchId); @@ -177,7 +194,9 @@ export class PiClient { state = "disposed"; const active = this.#watchListeners.get(watchId); try { - if (this.connected && this.#hello === connection) await this.#rpc.stopWatch(sessionId, watchId); + if (this.connected && this.#hello === connection && this.#attachment?.sessionId === sessionId) { + await this.#rpc.stopWatch(watchId); + } await active?.deliveryTail; } finally { this.#watchListeners.delete(watchId); @@ -186,7 +205,7 @@ export class PiClient { }; } - #request(call: ProtocolRpcCall | ServiceRpcCall, signal?: AbortSignal): Promise { + #request(target: RpcTarget, call: ProtocolRpcCall, signal?: AbortSignal): Promise { if (this.#disposed) return Promise.reject(new PiClientDisposedError()); if (!this.connected) return Promise.reject(new PiDisconnectedError()); if (signal?.aborted) return Promise.reject(abortError(signal)); @@ -199,10 +218,7 @@ export class PiClient { if (!sent || !this.connected) return; try { this.#connection.send( - encodeClientMessage( - { type: "cancel", id, serverId: this.#options.serverId }, - { maxFrameLength: this.#connection.maxFrameLength }, - ), + encodeClientMessage({ type: "cancel", id, target }, { maxFrameLength: this.#connection.maxFrameLength }), ); } catch (error) { this.#connection.fail(toError(error)); @@ -227,7 +243,7 @@ export class PiClient { let frame: Uint8Array; try { frame = encodeClientMessage( - { type: "request", id, serverId: this.#options.serverId, call }, + { type: "request", id, target, call }, { maxFrameLength: this.#connection.maxFrameLength }, ); } catch (error) { @@ -264,6 +280,7 @@ export class PiClient { #handleConnectionStateChange(change: ConnectionStateChange): void { if (change.state === "disconnected") { this.#hello = undefined; + this.#attachment = undefined; this.#rejectPendingRequests(change.error ?? new PiDisconnectedError()); this.#watchListeners.clear(); } @@ -302,6 +319,7 @@ export class PiClient { this.#rejectPendingRequests(error); this.#connection.disconnect(error); this.#hello = undefined; + this.#attachment = undefined; this.#connectionStateListeners.clear(); this.#watchListeners.clear(); return this.#disposePromise; @@ -311,6 +329,28 @@ export class PiClient { return this.dispose(); } + #targetForCall(call: ServiceRpcCall): RpcTarget { + switch (call.method) { + case "list": + case "create": + case "attach": + return { serverId: this.#options.serverId }; + case "prompt": + case "watch": + case "startWatch": + case "stopWatch": + return this.#requireSessionTarget(); + } + } + + #requireSessionTarget(sessionId?: string): SessionTarget { + const attachment = this.#attachment; + if (attachment === undefined || (sessionId !== undefined && attachment.sessionId !== sessionId)) { + throw new PiServerError({ code: "session_not_attached", message: "Session is not attached" }); + } + return attachment; + } + #assertNotDisposed(): void { if (this.#disposed) throw new PiClientDisposedError(); } diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 3f16d58f1..2ffee40cf 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -33,6 +33,19 @@ describe("PiClient service operations", () => { await expect(connectClient(wrong)).rejects.toBeInstanceOf(ProtocolValidationError); }); + test("rejects a Session address belonging to another server", async () => { + const server = new MemoryByteServer(); + const client = await connectClient(server); + await expect( + client.attachSession({ + serverId: "00000000-0000-4000-8000-000000000002", + sessionId: "session-1", + }), + ).rejects.toMatchObject({ code: "wrong_server" }); + expect(server.messages).toHaveLength(1); + await client.dispose(); + }); + test("addresses Session operations to the configured server", async () => { const server = new MemoryByteServer(); const client = await connectClient(server); @@ -41,58 +54,77 @@ describe("PiClient service operations", () => { expect(server.messages[1]).toEqual({ type: "request", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list", args: [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory", member: "list", args: [] }, }); server.send({ type: "response", id: "request-1", ok: true, - result: [{ id: "session-1", createdAt: 1, storageVersion: 1 }], + result: [ + { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + createdAt: 1, + }, + ], }); - await expect(listing).resolves.toEqual([{ id: "session-1", createdAt: 1, storageVersion: 1 }]); + await expect(listing).resolves.toEqual([ + { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + createdAt: 1, + }, + ]); - const creating = client.createSession({ cwd: "/workspace" }); + const creating = client.createSession({}); await server.waitForMessages(3); expect(server.messages[2]).toMatchObject({ type: "request", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "create", args: [{ cwd: "/workspace" }] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-management", member: "create", args: [{}] }, }); server.send({ type: "response", id: "request-2", ok: true, - result: { id: "session-2", createdAt: 2, storageVersion: 1, cwd: "/workspace" }, + result: { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-2", + createdAt: 2, + }, }); await expect(creating).resolves.toEqual({ - id: "session-2", + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-2", createdAt: 2, - storageVersion: 1, - cwd: "/workspace", }); const attaching = client.attachSession("session-1"); await server.waitForMessages(4); expect(server.messages[3]).toMatchObject({ type: "request", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "attach", args: ["session-1"] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-management", member: "attach", args: ["session-1"] }, }); server.send({ type: "response", id: "request-3", ok: true, - result: { sessionId: "session-1" }, + result: { sessionId: "session-1", attachmentId: "attachment-1" }, }); - await expect(attaching).resolves.toEqual({ sessionId: "session-1" }); + await expect(attaching).resolves.toEqual({ sessionId: "session-1", attachmentId: "attachment-1" }); const prompting = client.promptSession("session-1", "Hello"); await server.waitForMessages(5); expect(server.messages[4]).toMatchObject({ type: "request", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "prompt", args: ["session-1", ["Hello"]] }, + target: { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + attachmentId: "attachment-1", + }, + call: { serviceId: "chat", member: "prompt", args: [["Hello"]] }, }); server.send({ type: "response", @@ -110,6 +142,15 @@ describe("PiClient service operations", () => { test("serializes every supported prompt overload as one argument tuple", async () => { const server = new MemoryByteServer(); const client = await connectClient(server); + const attaching = client.attachSession("session-1"); + await server.waitForMessages(2); + server.send({ + type: "response", + id: "request-1", + ok: true, + result: { sessionId: "session-1", attachmentId: "attachment-1" }, + }); + await attaching; const calls = [ client.promptSession("session-1", "text"), client.promptSession("session-1", "image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]), @@ -119,20 +160,21 @@ describe("PiClient service operations", () => { { role: "user", content: "second", timestamp: 2 }, ]), ]; - await server.waitForMessages(5); + await server.waitForMessages(6); expect( - server.messages.slice(1).map((message) => (message.type === "request" ? message.call : undefined)), + server.messages.slice(2).map((message) => (message.type === "request" ? message.call : undefined)), ).toEqual([ - { method: "prompt", args: ["session-1", ["text"]] }, + { serviceId: "chat", member: "prompt", args: [["text"]] }, { - method: "prompt", - args: ["session-1", ["image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]]], + serviceId: "chat", + member: "prompt", + args: [["image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]]], }, - { method: "prompt", args: ["session-1", [{ role: "user", content: "message", timestamp: 1 }]] }, + { serviceId: "chat", member: "prompt", args: [[{ role: "user", content: "message", timestamp: 1 }]] }, { - method: "prompt", + serviceId: "chat", + member: "prompt", args: [ - "session-1", [ [ { role: "user", content: "first", timestamp: 1 }, @@ -145,7 +187,7 @@ describe("PiClient service operations", () => { for (let index = 0; index < calls.length; index++) { server.send({ type: "response", - id: `request-${index + 1}`, + id: `request-${index + 2}`, ok: true, result: { ok: false, error: { _tag: "Closed", message: "closed" } }, }); @@ -157,13 +199,22 @@ describe("PiClient service operations", () => { test("starts lane event delivery only after receiving the authoritative snapshot", async () => { const server = new MemoryByteServer(); const client = await connectClient(server); - const opening = client.watchSession("session-1"); + const attaching = client.attachSession("session-1"); await server.waitForMessages(2); - expect(server.messages[1]).toMatchObject({ call: { method: "watch", args: ["session-1"] } }); server.send({ type: "response", id: "request-1", ok: true, + result: { sessionId: "session-1", attachmentId: "attachment-1" }, + }); + await attaching; + const opening = client.watchSession("session-1"); + await server.waitForMessages(3); + expect(server.messages[2]).toMatchObject({ call: { serviceId: "transcript", member: "watch", args: [] } }); + server.send({ + type: "response", + id: "request-2", + ok: true, result: { watchId: "watch-1", snapshot: { @@ -189,9 +240,9 @@ describe("PiClient service operations", () => { await eventGate; events.push(event.type); }); - await server.waitForMessages(3); - expect(server.messages[2]).toMatchObject({ - call: { method: "startWatch", args: ["session-1", "watch-1"] }, + await server.waitForMessages(4); + expect(server.messages[3]).toMatchObject({ + call: { serviceId: "transcript", member: "startWatch", args: ["watch-1"] }, }); server.send({ type: "event", @@ -200,18 +251,18 @@ describe("PiClient service operations", () => { }); await Promise.resolve(); expect(events).toEqual([]); - server.send({ type: "response", id: "request-2", ok: true, result: { watchId: "watch-1" } }); + server.send({ type: "response", id: "request-3", ok: true, result: { watchId: "watch-1" } }); await starting; let disposed = false; const disposing = watch.dispose().then(() => { disposed = true; }); - await server.waitForMessages(4); - expect(server.messages[3]).toMatchObject({ - call: { method: "stopWatch", args: ["session-1", "watch-1"] }, + await server.waitForMessages(5); + expect(server.messages[4]).toMatchObject({ + call: { serviceId: "transcript", member: "stopWatch", args: ["watch-1"] }, }); - server.send({ type: "response", id: "request-3", ok: true, result: { watchId: "watch-1" } }); + server.send({ type: "response", id: "request-4", ok: true, result: { watchId: "watch-1" } }); await Promise.resolve(); expect(disposed).toBe(false); releaseEvent(); @@ -236,7 +287,7 @@ describe("PiClient service operations", () => { type: "response", id: "request-2", ok: true, - result: { sessionId: "session-1" }, + result: { sessionId: "session-1", attachmentId: "attachment-1" }, }); server.send({ type: "response", @@ -244,7 +295,10 @@ describe("PiClient service operations", () => { ok: true, result: [], }); - await expect(Promise.all([first, second])).resolves.toEqual([[], { sessionId: "session-1" }]); + await expect(Promise.all([first, second])).resolves.toEqual([ + [], + { sessionId: "session-1", attachmentId: "attachment-1" }, + ]); await client.dispose(); }); @@ -270,7 +324,13 @@ describe("PiClient service operations", () => { const reason = new Error("already cancelled"); controller.abort(reason); - await expect(client.invoke("session.test", {}, controller.signal)).rejects.toBe(reason); + await expect( + client.request( + { serverId: "00000000-0000-4000-8000-000000000001" }, + { serviceId: "test", member: "noop", args: [] }, + controller.signal, + ), + ).rejects.toBe(reason); expect(server.messages).toHaveLength(1); await client.dispose(); }); @@ -280,12 +340,18 @@ describe("PiClient service operations", () => { const client = await connectClient(server); const controller = new AbortController(); const reason = new Error("stop this request"); - const pending = client.invoke("session.test", { value: 42 }, controller.signal); + const target = { serverId: "00000000-0000-4000-8000-000000000001" } as const; + const pending = client.request( + target, + { serviceId: "test", member: "mutate", args: [{ value: 42 }] }, + controller.signal, + ); await server.waitForMessages(2); expect(server.messages[1]).toMatchObject({ type: "request", id: "request-1", - call: { method: "session.test", args: { value: 42 } }, + target, + call: { serviceId: "test", member: "mutate", args: [{ value: 42 }] }, }); controller.abort(reason); @@ -294,7 +360,7 @@ describe("PiClient service operations", () => { expect(server.messages[2]).toEqual({ type: "cancel", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", + target, }); server.send({ type: "response", @@ -396,9 +462,18 @@ describe("PiClient connection lifecycle", () => { const states: string[] = []; client.onConnectionStateChange(({ state }) => states.push(state)); await client.connect(); - const pending = client.promptSession("session-1", "Hello"); + const attaching = client.attachSession("session-1"); await first.waitForMessages(2); - expect(first.messages[1]).toMatchObject({ call: { method: "prompt" } }); + first.send({ + type: "response", + id: "request-1", + ok: true, + result: { sessionId: "session-1", attachmentId: "attachment-1" }, + }); + await attaching; + const pending = client.promptSession("session-1", "Hello"); + await first.waitForMessages(3); + expect(first.messages[2]).toMatchObject({ call: { serviceId: "chat", member: "prompt" } }); first.disconnect(); await expect(pending).rejects.toBeInstanceOf(PiDisconnectedError); diff --git a/packages/client/test/unix-transport.test.ts b/packages/client/test/unix-transport.test.ts index 134ff08c2..e037eb9d2 100644 --- a/packages/client/test/unix-transport.test.ts +++ b/packages/client/test/unix-transport.test.ts @@ -53,7 +53,7 @@ test("rejects invalid Unix transport options", () => { describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () => { test("carries a complete PiClient handshake and request over a real Unix socket", async () => { const path = await makeSocketPath(); - const receivedMethods: string[] = []; + const receivedMembers: string[] = []; const server = createServer((socket) => { const decoder = new ClientMessageDecoder(); socket.on("data", (chunk) => { @@ -68,7 +68,7 @@ describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () => continue; } if (message.type === "cancel") continue; - receivedMethods.push(message.call.method); + receivedMembers.push(`${message.call.serviceId}.${message.call.member}`); const frame = encodeServerMessage({ type: "response", id: message.id, @@ -87,7 +87,7 @@ describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () => try { await expect(client.connect()).resolves.toMatchObject({ serverId }); await expect(client.listSessions()).resolves.toEqual([]); - expect(receivedMethods).toEqual(["list"]); + expect(receivedMembers).toEqual(["session-directory.list"]); } finally { await client.dispose(); } diff --git a/packages/client/test/unix.test.ts b/packages/client/test/unix.test.ts index 3e50f0cfe..69317d04a 100644 --- a/packages/client/test/unix.test.ts +++ b/packages/client/test/unix.test.ts @@ -36,7 +36,7 @@ async function startServer( list: async () => [], create: async () => Promise.reject(new Error("unused")), }, - createHarness: async () => Promise.reject(new Error("unused")), + openSession: async () => Promise.reject(new Error("unused")), }, { listeners: [createUnixListener({ path })], serverId: reportedServerId }, ); diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index 68365ad32..4925b261c 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -1,15 +1,14 @@ import { basename } from "node:path"; -import { BACKGROUND_CONTEXT, RemoteSession, type RemoteSessionRpc } from "@earendil-works/pi-agent-core"; import { PiClient } from "@earendil-works/pi-client"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; -import { isServerId, type LaneEvent } from "@earendil-works/pi-protocol"; +import { isServerId, type LaneEvent, type SessionAddress } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts"; export type ClientResult = | { readonly kind: "list"; - readonly sessions: readonly { serverId: string; sessionId: string }[]; + readonly sessions: readonly SessionAddress[]; } | { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string } | { readonly kind: "prompted"; readonly serverId: string; readonly sessionId: string; readonly text: string }; @@ -68,7 +67,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption activatedClient = undefined; openedClients.add(client); const sessions = await client.listSessions(); - discovered.push({ route, sessionIds: sessions.map(({ id }) => id), client }); + discovered.push({ route, sessionIds: sessions.map(({ sessionId }) => sessionId), client }); } let sessionId = command.sessionId; @@ -92,7 +91,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption throw new Error("Client prompt requires exactly one discovered server to create a Session"); } match = discovered[0]!; - sessionId = (await match.client.createSession({ cwd: process.cwd() })).id; + sessionId = (await match.client.createSession({})).sessionId; } else { const selectedSessionId = sessionId; const matches = discovered.filter((candidate) => candidate.sessionIds.includes(selectedSessionId)); @@ -107,21 +106,31 @@ export async function runClient(command: ClientCommand, options: RunClientOption throw new Error(`No discovered server contains session ${selectedSessionId}`); } match = discovered[0]!; - await match.client.createSession({ id: selectedSessionId, cwd: process.cwd() }); + await match.client.createSession({ id: selectedSessionId }); } } - const rpc: RemoteSessionRpc = { - invoke: (method, args, context) => match.client.invoke(method, args, context.abortSignal), - }; - const session = await RemoteSession.open(rpc, sessionId, BACKGROUND_CONTEXT); + await match.client.attachSession({ serverId: match.route.serverId, sessionId }); if (command.prompt === undefined) { - await session.close(BACKGROUND_CONTEXT); - return { kind: "attached", serverId: match.route.serverId, sessionId: session.metadata.id }; + return { kind: "attached", serverId: match.route.serverId, sessionId }; } + + const watch = options.onEvent === undefined ? undefined : await match.client.watchSession(sessionId); + if (watch && options.onEvent) await watch.start(options.onEvent); try { - throw new Error("Experimental client prompting through AgentHarness is not implemented"); + const result = await match.client.promptSession(sessionId, command.prompt); + if (!result.ok) throw new Error(result.error.message); + const value = result.value; + if (value.kind === "failed") throw new Error(value.error.message); + const text = + "finalMessage" in value + ? value.finalMessage.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + : ""; + return { kind: "prompted", serverId: match.route.serverId, sessionId, text }; } finally { - await session.close(BACKGROUND_CONTEXT); + await watch?.dispose(); } } finally { await Promise.all([...openedClients].map((client) => client.dispose())); diff --git a/packages/coding-agent/src/experimental/coordinator.ts b/packages/coding-agent/src/experimental/coordinator.ts index c10954ab2..ddcdd6e86 100644 --- a/packages/coding-agent/src/experimental/coordinator.ts +++ b/packages/coding-agent/src/experimental/coordinator.ts @@ -12,7 +12,7 @@ import { spawnInternalProcess, } from "./process.ts"; -const COORDINATOR_PROTOCOL_VERSION = 1; +export const COORDINATOR_PROTOCOL_VERSION = 2; const COORDINATOR_START_TIMEOUT_MS = 10_000; const COORDINATOR_RETRY_MS = 10; diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts index f18b96151..55dba09de 100644 --- a/packages/coding-agent/src/experimental/server.ts +++ b/packages/coding-agent/src/experimental/server.ts @@ -8,7 +8,7 @@ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { PiClient, PiDisconnectedError, PiServerError } from "@earendil-works/pi-client"; import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; -import { type PiServer, type PiServerHost, SessionInUseError } from "@earendil-works/pi-server"; +import type { PiServer, PiServerHost } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../config.ts"; @@ -339,21 +339,15 @@ async function startServerBackend( return [...sessions.values()]; }, create: async (createOptions, context) => { - const session = await repo.create(createOptions, context); + const session = await repo.create({ ...createOptions, cwd: process.cwd() }, context); try { return session.metadata; } finally { await session.close(context); } }, - open: (metadata, context) => { - if (workers.hasSession(metadata.path)) { - throw new SessionInUseError(); - } - return repo.open(metadata, context); - }, }, - createHarness: (metadata, context) => workers.createHarness(metadata, context), + openSession: (metadata, context) => workers.openSession(metadata, context), }; const socketPath = options.path; const closeCatalog = async (): Promise => { diff --git a/packages/coding-agent/src/experimental/session-worker-manager.ts b/packages/coding-agent/src/experimental/session-worker-manager.ts index 6a53f611a..c88ba8fc1 100644 --- a/packages/coding-agent/src/experimental/session-worker-manager.ts +++ b/packages/coding-agent/src/experimental/session-worker-manager.ts @@ -8,7 +8,7 @@ import { TODO_CONTEXT, } from "@earendil-works/pi-agent-core"; import { createRpcClient, type LaneEvent } from "@earendil-works/pi-protocol"; -import type { HostedHarnessAttachment, HostedHarnessHandle, HostedHarnessWatch } from "@earendil-works/pi-server"; +import type { RoutedSessionAttachment, RoutedSessionHandle, RoutedSessionWatch } from "@earendil-works/pi-server"; import { Check } from "typebox/value"; import type { CoordinatorConnection, CoordinatorConnectionEvent } from "./coordinator.ts"; import { spawnInternalProcess } from "./process.ts"; @@ -38,14 +38,15 @@ interface WorkerRecord { readonly token: string; readonly terminated: Promise; resolveTerminated(error: Error | undefined): void; - attachmentId?: string; + readonly attachmentIds: Set; expectedStop: boolean; stopPromise?: Promise; stopping: boolean; } interface PendingDemand { - readonly attachmentId: string | null; + readonly attachmentId: string; + readonly attached: boolean; readonly requestId: string; readonly timer: NodeJS.Timeout; readonly worker: WorkerRecord; @@ -120,10 +121,6 @@ export class SessionWorkerManager { return [...this.#workersBySession.values()].map((worker) => worker.metadata); } - hasSession(path: string): boolean { - return this.#workersBySession.has(path) || this.#pending.has(path); - } - async discover(peerIds: ReadonlySet): Promise { if (this.#detached) return; const undiscovered = new Set( @@ -148,70 +145,74 @@ export class SessionWorkerManager { this.#resolveDiscovery = undefined; } - async createHarness(metadata: JsonlSessionMetadata, context: Context): Promise { + async openSession(metadata: JsonlSessionMetadata, context: Context): Promise { if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down"); const existing = this.#workersBySession.get(metadata.path); - if (existing) return this.#hostedHandle(existing); + if (existing) return this.#routedHandle(existing); const pending = this.#pending.get(metadata.path); - if (pending) return this.#hostedHandle(await pending.promise); - return this.#hostedHandle(await this.#launch(metadata, context)); + if (pending) return this.#routedHandle(await pending.promise); + return this.#routedHandle(await this.#launch(metadata, context)); } - #hostedHandle(worker: WorkerRecord): HostedHarnessHandle { + #routedHandle(worker: WorkerRecord): RoutedSessionHandle { return { terminated: worker.terminated, attachClient: (context) => this.#attachClient(worker, context), - prompt: (prompt, context) => this.#operations(worker, context).prompt(prompt), - watch: (context) => this.#createLaneWatch(worker, context), close: (context) => this.#stopWorker(worker, context), }; } #operations( worker: WorkerRecord, + scope: WorkerOperationScope, context: Context, ): ReturnType> { - return createRpcClient(SessionWorkerOperations, (call) => this.#invoke(worker, call, context)); + return createRpcClient(SessionWorkerOperations, (call) => this.#invoke(worker, scope, call, context)); } - async #attachClient(worker: WorkerRecord, context: Context): Promise { + async #attachClient(worker: WorkerRecord, context: Context): Promise { if (this.#detached || this.#shuttingDown || worker.stopping) { throw new Error("Experimental Session worker is stopping"); } if (this.#workersByPeer.get(worker.peerId) !== worker) { throw new Error("Experimental Session worker is no longer available"); } - if (worker.attachmentId !== undefined) throw new Error("Experimental Session is already attached"); const attachmentId = randomUUID(); - worker.attachmentId = attachmentId; + worker.attachmentIds.add(attachmentId); try { - await this.#applyDemand(worker, attachmentId, true, context); + await this.#applyDemand(worker, attachmentId, true, true, context); } catch (error) { - if (worker.attachmentId === attachmentId) worker.attachmentId = undefined; + worker.attachmentIds.delete(attachmentId); throw error; } + const scope = this.#operationScope(worker, attachmentId); let released = false; return { + prompt: (prompt, promptContext) => this.#operations(worker, scope, promptContext).prompt(prompt), + watch: (watchContext) => this.#createLaneWatch(worker, scope, watchContext), release: async (releaseContext) => { if (released) return; released = true; - if (this.#detached || worker.attachmentId !== attachmentId) return; + if (this.#detached || !worker.attachmentIds.has(attachmentId)) return; try { - await this.#applyDemand(worker, null, true, releaseContext); + await this.#applyDemand(worker, attachmentId, false, true, releaseContext); } catch (error) { if (!this.#detached && !this.#coordinator.wasReplaced) throw error; } finally { - if (worker.attachmentId === attachmentId) worker.attachmentId = undefined; + worker.attachmentIds.delete(attachmentId); } }, }; } - async #createLaneWatch(worker: WorkerRecord, context: Context): Promise { - const scope = this.#operationScope(worker); - const { watchId, snapshot } = await this.#operations(worker, context).watch(); + async #createLaneWatch( + worker: WorkerRecord, + scope: WorkerOperationScope, + context: Context, + ): Promise { + const { watchId, snapshot } = await this.#operations(worker, scope, context).watch(); if (this.#laneWatches.has(watchId)) { - await this.#operations(worker, context) + await this.#operations(worker, scope, context) .stopWatch(watchId) .catch(() => {}); throw new Error("Session worker reused an active lane watch ID"); @@ -228,12 +229,12 @@ export class SessionWorkerManager { state = "started"; watch.listener = listener; try { - await this.#operations(worker, startContext).startWatch(watchId); + await this.#operations(worker, scope, startContext).startWatch(watchId); } catch (error) { this.#laneWatches.delete(watchId); delete watch.listener; state = "unsubscribed"; - await this.#operations(worker, startContext) + await this.#operations(worker, scope, startContext) .stopWatch(watchId) .catch(() => {}); throw error; @@ -244,14 +245,15 @@ export class SessionWorkerManager { state = "unsubscribed"; if (this.#laneWatches.get(watchId) === watch) this.#laneWatches.delete(watchId); delete watch.listener; - await this.#operations(worker, unsubscribeContext).stopWatch(watchId); + await this.#operations(worker, scope, unsubscribeContext).stopWatch(watchId); }, }; } async #applyDemand( worker: WorkerRecord, - attachmentId: string | null, + attachmentId: string, + attached: boolean, compensateOnTimeout: boolean, _context: Context, ): Promise { @@ -270,7 +272,7 @@ export class SessionWorkerManager { else this.#rejectDemand(requestId, new Error("Session worker demand update timed out")); }, WORKER_DEMAND_TIMEOUT_MS); timer.unref(); - const pending = { attachmentId, requestId, timer, worker, resolve, reject }; + const pending = { attachmentId, attached, requestId, timer, worker, resolve, reject }; this.#pendingDemand.set(requestId, pending); try { await this.#coordinator.send(worker.peerId, { @@ -278,6 +280,7 @@ export class SessionWorkerManager { serverConnectionId: this.#coordinator.serverConnectionId, requestId, attachmentId, + attached, }); } catch (error) { this.#rejectDemand(requestId, error instanceof Error ? error : new Error(String(error))); @@ -286,10 +289,14 @@ export class SessionWorkerManager { } /** Worker operations have no wall-clock timeout: completion, disconnect, replacement, or shutdown settles them. */ - #invoke(worker: WorkerRecord, call: SessionWorkerOperationCall, _context: Context): Promise { - let scope: WorkerOperationScope; + #invoke( + worker: WorkerRecord, + scope: WorkerOperationScope, + call: SessionWorkerOperationCall, + _context: Context, + ): Promise { try { - scope = this.#operationScope(worker); + this.#operationScope(worker, scope.attachmentId); } catch (error) { return Promise.reject(error); } @@ -309,16 +316,16 @@ export class SessionWorkerManager { return result; } - #operationScope(worker: WorkerRecord): WorkerOperationScope { + #operationScope(worker: WorkerRecord, attachmentId: string): WorkerOperationScope { if (this.#detached || this.#shuttingDown || worker.stopping) { throw new Error("Experimental Session worker is stopping"); } - if (this.#workersByPeer.get(worker.peerId) !== worker || worker.attachmentId === undefined) { + if (this.#workersByPeer.get(worker.peerId) !== worker || !worker.attachmentIds.has(attachmentId)) { throw new Error("Experimental Session worker has no active attachment"); } return { serverConnectionId: this.#coordinator.serverConnectionId, - attachmentId: worker.attachmentId, + attachmentId, }; } @@ -486,7 +493,8 @@ export class SessionWorkerManager { pending.worker.peerId !== event.from || pending.worker.token !== message.token || pending.worker.metadata.path !== message.sessionKey || - (message.type === "demand_applied" && pending.attachmentId !== message.attachmentId) + (message.type === "demand_applied" && + (pending.attachmentId !== message.attachmentId || pending.attached !== message.attached)) ) { return; } @@ -601,6 +609,7 @@ export class SessionWorkerManager { token: message.token, terminated, resolveTerminated, + attachmentIds: new Set(), expectedStop: false, stopping: false, }; @@ -672,9 +681,9 @@ export class SessionWorkerManager { clearTimeout(pending.timer); const timeoutError = new Error("Session worker demand update timed out"); try { - await this.#applyDemand(pending.worker, null, false, BACKGROUND_CONTEXT); - if (pending.attachmentId === null) pending.resolve(); - else pending.reject(timeoutError); + await this.#applyDemand(pending.worker, pending.attachmentId, false, false, BACKGROUND_CONTEXT); + if (pending.attached) pending.reject(timeoutError); + else pending.resolve(); } catch (cleanupError) { try { await this.#stopWorker(pending.worker, BACKGROUND_CONTEXT); diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 13d0ec825..b049f63cb 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -32,6 +32,7 @@ import { Check } from "typebox/value"; import { findInitialModel, resolveCliModel } from "../core/model-resolver.ts"; import { ModelRuntime } from "../core/model-runtime.ts"; import { SettingsManager } from "../core/settings-manager.ts"; +import { COORDINATOR_PROTOCOL_VERSION } from "./coordinator.ts"; import { toHarnessPromptArguments, toWireLaneEvent, @@ -132,7 +133,8 @@ export const SessionWorkerCommandSchema = Type.Union([ type: Type.Literal("session_demand"), serverConnectionId: Type.String(), requestId: Type.String(), - attachmentId: Type.Union([Type.String(), Type.Null()]), + attachmentId: Type.String(), + attached: Type.Boolean(), }), WorkerOperationRequestSchema, ]); @@ -158,7 +160,8 @@ export const SessionWorkerEventSchema = Type.Union([ token: Type.String(), sessionKey: Type.String(), requestId: Type.String(), - attachmentId: Type.Union([Type.String(), Type.Null()]), + attachmentId: Type.String(), + attached: Type.Boolean(), }), Type.Object({ type: Type.Literal("demand_rejected"), @@ -189,7 +192,7 @@ export class WorkerLifecycle { readonly #initialDemandGraceMs: number; readonly #orphanDemandGraceMs: number; readonly #onRetire: () => void; - readonly #demands = new Map(); + readonly #demands = new Map(); readonly #activeOperations = new Set(); #currentServerConnectionId: string | undefined; #initialTimer: NodeJS.Timeout | undefined; @@ -218,8 +221,8 @@ export class WorkerLifecycle { serverConnected(serverConnectionId: string): void { this.#currentServerConnectionId = serverConnectionId; - const demand = this.#demands.get(serverConnectionId); - if (demand?.timer) { + for (const demand of this.#demands.values()) { + if (demand.serverConnectionId !== serverConnectionId || !demand.timer) continue; clearTimeout(demand.timer); delete demand.timer; } @@ -227,14 +230,15 @@ export class WorkerLifecycle { serverDisconnected(serverConnectionId: string): void { if (this.#currentServerConnectionId === serverConnectionId) this.#currentServerConnectionId = undefined; - const demand = this.#demands.get(serverConnectionId); - if (!demand || demand.timer) return; - demand.timer = setTimeout(() => { - if (this.#demands.get(serverConnectionId) !== demand) return; - this.#demands.delete(serverConnectionId); - this.#reconcile(); - }, this.#orphanDemandGraceMs); - demand.timer.unref(); + for (const [key, demand] of this.#demands) { + if (demand.serverConnectionId !== serverConnectionId || demand.timer) continue; + demand.timer = setTimeout(() => { + if (this.#demands.get(key) !== demand) return; + this.#demands.delete(key); + this.#reconcile(); + }, this.#orphanDemandGraceMs); + demand.timer.unref(); + } } beginRequest(serverConnectionId: string, attachmentId: string): () => void { @@ -242,8 +246,8 @@ export class WorkerLifecycle { if (serverConnectionId !== this.#currentServerConnectionId) { throw new Error("Session worker received a request from a stale server generation"); } - const demand = this.#demands.get(serverConnectionId); - if (!demand || demand.timer || demand.attachmentId !== attachmentId) { + const demand = this.#demands.get(demandKey(serverConnectionId, attachmentId)); + if (!demand || demand.timer) { throw new Error("Session worker request does not match the active attachment"); } return this.holdRetirement(); @@ -260,7 +264,7 @@ export class WorkerLifecycle { }; } - setDemand(serverConnectionId: string, attachmentId: string | null): void { + setDemand(serverConnectionId: string, attachmentId: string, attached: boolean): void { if (this.#retiring) throw new Error("Session worker is retiring"); if (serverConnectionId !== this.#currentServerConnectionId) { throw new Error("Session worker received demand from a stale server generation"); @@ -270,10 +274,11 @@ export class WorkerLifecycle { clearTimeout(this.#initialTimer); this.#initialTimer = undefined; } - const previous = this.#demands.get(serverConnectionId); + const key = demandKey(serverConnectionId, attachmentId); + const previous = this.#demands.get(key); if (previous?.timer) clearTimeout(previous.timer); - if (attachmentId === null) this.#demands.delete(serverConnectionId); - else this.#demands.set(serverConnectionId, { attachmentId }); + if (attached) this.#demands.set(key, { serverConnectionId, attachmentId }); + else this.#demands.delete(key); this.#reconcile(); } @@ -348,7 +353,7 @@ async function connectControl(): Promise { socket.once("error", reject); }); const messages = createJsonLineMessages(socket); - await writeJsonLine(socket, { type: "register_peer", protocol: 1, peerId }); + await writeJsonLine(socket, { type: "register_peer", protocol: COORDINATOR_PROTOCOL_VERSION, peerId }); const registered = await messages[Symbol.asyncIterator]().next(); if ( registered.done || @@ -449,6 +454,10 @@ function writeJsonLine(socket: Socket, message: unknown): Promise { }); } +function demandKey(serverConnectionId: string, attachmentId: string): string { + return `${serverConnectionId}\0${attachmentId}`; +} + function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean { return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId; } @@ -670,12 +679,14 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const releaseRetirement = lifecycle?.holdRetirement() ?? (() => {}); try { try { - removeLaneWatches( - (scope) => - scope.serverConnectionId === command.serverConnectionId && - scope.attachmentId !== command.attachmentId, - ); - lifecycle?.setDemand(command.serverConnectionId, command.attachmentId); + if (!command.attached) { + removeLaneWatches( + (scope) => + scope.serverConnectionId === command.serverConnectionId && + scope.attachmentId === command.attachmentId, + ); + } + lifecycle?.setDemand(command.serverConnectionId, command.attachmentId, command.attached); } catch (error) { await control.send({ type: "demand_rejected", @@ -692,6 +703,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo sessionKey, requestId: command.requestId, attachmentId: command.attachmentId, + attached: command.attached, }); } finally { releaseRetirement(); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index c82b8d733..bd7f608de 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -273,14 +273,14 @@ describe("experimental durable server composition", () => { expect(socket.mode & 0o777).toBe(0o600); }); - test("keeps one worker for one connection-scoped Session attachment", async () => { + test("shares one worker across concurrent Session attachments", async () => { const { runtime } = await makeServer(); const client = await attachClient(runtime, "demo-1"); expect([...runtime.workerPids.keys()]).toEqual(["demo-1"]); const firstPid = runtime.workerPids.get("demo-1"); expect(firstPid).toEqual(expect.any(Number)); - await expect(client.attachSession("demo-1")).resolves.toEqual({ sessionId: "demo-1" }); + await expect(client.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" }); expect(runtime.workerPids.get("demo-1")).toBe(firstPid); const competing = await PiClient.connect({ @@ -288,22 +288,19 @@ describe("experimental durable server composition", () => { transportFactory: createUnixTransportFactory({ path: runtime.socketPath }), }); clients.add(competing); - await expect(competing.attachSession("demo-1")).rejects.toMatchObject({ code: "session_in_use" }); + await expect(competing.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" }); + expect(runtime.workerPids.get("demo-1")).toBe(firstPid); }); - test("rejects prompting until AgentHarness prompting is implemented", async () => { + test("routes prompting through the worker-owned Harness and sanitizes runtime failures", async () => { const { directory } = await makeServer(); await expect( runClient({ command: "client", sessionId: "demo-1", prompt: "question" }, { directory }), - ).rejects.toThrow("Experimental client prompting through AgentHarness is not implemented"); - await expect(runClient({ command: "client", sessionId: "demo-1" }, { directory })).resolves.toMatchObject({ - kind: "attached", - sessionId: "demo-1", - }); + ).rejects.toThrow("Internal server error"); }); - // WP00 intentionally selects execution-incomplete runtime2. Re-enable with the no-tool run package. + // Runtime2 cannot complete no-tool runs yet; keep the framed prompt path covered as a sanitized failure above. test.skip("streams prompt events from the worker to the client", async ({ onTestFinished }) => { const spawn = vi .spyOn(processRuntime, "spawnInternalProcess") @@ -346,7 +343,7 @@ describe("experimental durable server composition", () => { ); }); - // WP00 intentionally selects execution-incomplete runtime2. Re-enable with the no-tool run package. + // Re-enable with runtime2 no-tool execution. test.skip("completes and persists a prompt through the worker-owned Harness", async ({ onTestFinished }) => { const spawn = vi .spyOn(processRuntime, "spawnInternalProcess") @@ -424,7 +421,7 @@ describe("experimental durable server composition", () => { }); clients.add(client); - await expect(client.attachSession("demo-1")).resolves.toEqual({ sessionId: "demo-1" }); + await expect(client.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" }); expect(runtime.workerPids.get("demo-1")).toEqual(expect.any(Number)); }); diff --git a/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts b/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts index 26376f424..53a00e45d 100644 --- a/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts @@ -22,9 +22,9 @@ describe("Session worker lifecycle", () => { test("retires only after client demand and Harness activity are both gone", async () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); lifecycle.operationStarted("run", "main", "operation-1"); - lifecycle.setDemand(GENERATION, null); + lifecycle.setDemand(GENERATION, "attachment-1", false); expect(retire).not.toHaveBeenCalled(); lifecycle.operationStopped("run", "main", "operation-1"); @@ -33,13 +33,25 @@ describe("Session worker lifecycle", () => { lifecycle.close(); }); + test("retains the worker until every presentation attachment is released", () => { + vi.useFakeTimers(); + const { lifecycle, retire } = createLifecycle(); + lifecycle.setDemand(GENERATION, "attachment-1", true); + lifecycle.setDemand(GENERATION, "attachment-2", true); + lifecycle.setDemand(GENERATION, "attachment-1", false); + expect(retire).not.toHaveBeenCalled(); + lifecycle.setDemand(GENERATION, "attachment-2", false); + expect(retire).toHaveBeenCalledOnce(); + lifecycle.close(); + }); + test("tracks a nested compaction independently from its enclosing run", () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); lifecycle.operationStarted("run", "main", "operation-1"); lifecycle.operationStarted("compaction", "main", "operation-1"); - lifecycle.setDemand(GENERATION, null); + lifecycle.setDemand(GENERATION, "attachment-1", false); lifecycle.operationStopped("compaction", "main", "operation-1"); expect(retire).not.toHaveBeenCalled(); @@ -51,9 +63,9 @@ describe("Session worker lifecycle", () => { test.each(["compaction", "navigation"] as const)("clears suspended %s activity", (kind) => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); lifecycle.operationStarted(kind, "main", "operation-1"); - lifecycle.setDemand(GENERATION, null); + lifecycle.setDemand(GENERATION, "attachment-1", false); lifecycle.operationStopped(kind, "main", "operation-1"); expect(retire).toHaveBeenCalledOnce(); @@ -63,9 +75,9 @@ describe("Session worker lifecycle", () => { test("does not retire while a demand acknowledgement holds reconciliation", () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); const release = lifecycle.holdRetirement(); - lifecycle.setDemand(GENERATION, null); + lifecycle.setDemand(GENERATION, "attachment-1", false); expect(retire).not.toHaveBeenCalled(); release(); expect(retire).toHaveBeenCalledOnce(); @@ -75,9 +87,9 @@ describe("Session worker lifecycle", () => { test("holds retirement only for requests from the active attachment", () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); const release = lifecycle.beginRequest(GENERATION, "attachment-1"); - lifecycle.setDemand(GENERATION, null); + lifecycle.setDemand(GENERATION, "attachment-1", false); expect(retire).not.toHaveBeenCalled(); release(); expect(retire).toHaveBeenCalledOnce(); @@ -88,7 +100,7 @@ describe("Session worker lifecycle", () => { test("rejects requests from stale generations and attachments", () => { vi.useFakeTimers(); const { lifecycle } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); expect(() => lifecycle.beginRequest("stale", "attachment-1")).toThrow(/stale server generation/); expect(() => lifecycle.beginRequest(GENERATION, "wrong-attachment")).toThrow(/active attachment/); lifecycle.close(); @@ -97,7 +109,7 @@ describe("Session worker lifecycle", () => { test("retains disconnected-generation demand for the orphan grace", async () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); lifecycle.serverDisconnected(GENERATION); vi.advanceTimersByTime(199); @@ -111,14 +123,14 @@ describe("Session worker lifecycle", () => { test("allows a replacement generation to retain the worker", async () => { vi.useFakeTimers(); const { lifecycle, retire } = createLifecycle(); - lifecycle.setDemand(GENERATION, "attachment-1"); + lifecycle.setDemand(GENERATION, "attachment-1", true); lifecycle.serverDisconnected(GENERATION); lifecycle.serverConnected("generation-2"); - lifecycle.setDemand("generation-2", "attachment-2"); + lifecycle.setDemand("generation-2", "attachment-2", true); vi.advanceTimersByTime(200); expect(retire).not.toHaveBeenCalled(); - lifecycle.setDemand("generation-2", null); + lifecycle.setDemand("generation-2", "attachment-2", false); await vi.runAllTicks(); expect(retire).toHaveBeenCalledOnce(); lifecycle.close(); @@ -137,14 +149,14 @@ describe("Session worker lifecycle", () => { vi.useFakeTimers(); const { lifecycle } = createLifecycle(); vi.advanceTimersByTime(100); - expect(() => lifecycle.setDemand(GENERATION, "attachment-1")).toThrow(/retiring/); + expect(() => lifecycle.setDemand(GENERATION, "attachment-1", true)).toThrow(/retiring/); lifecycle.close(); }); test("rejects demand from a stale server generation", () => { vi.useFakeTimers(); const { lifecycle } = createLifecycle(); - expect(() => lifecycle.setDemand("stale", "attachment-1")).toThrow(/stale server generation/); + expect(() => lifecycle.setDemand("stale", "attachment-1", true)).toThrow(/stale server generation/); lifecycle.close(); }); }); diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts index cc807cdaa..845b3e89b 100644 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-manager.test.ts @@ -59,13 +59,14 @@ afterEach(() => { async function createAttachedWorker(): Promise<{ coordinator: FakeCoordinator; workers: SessionWorkerManager; - handle: Awaited>; + handle: Awaited>; + attachment: Awaited>["attachClient"]>>; release(): Promise; }> { const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.createHarness(metadata, BACKGROUND_CONTEXT); + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); coordinator.onSend = (peerId, payload) => { if (payload.type !== "session_demand") return; queueMicrotask(() => @@ -78,6 +79,7 @@ async function createAttachedWorker(): Promise<{ sessionKey: metadata.path, requestId: payload.requestId, attachmentId: payload.attachmentId, + attached: payload.attached, }, }), ); @@ -87,6 +89,7 @@ async function createAttachedWorker(): Promise<{ coordinator, workers, handle, + attachment, release: () => Promise.resolve(attachment.release(BACKGROUND_CONTEXT)), }; } @@ -97,12 +100,18 @@ describe("Session worker lifecycle failures", () => { const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.createHarness(metadata, BACKGROUND_CONTEXT); - const demands: (string | null)[] = []; + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); + const demands: { attachmentId: string; attached: boolean }[] = []; coordinator.onSend = (peerId, payload) => { - if (payload.type !== "session_demand") return; - demands.push(typeof payload.attachmentId === "string" ? payload.attachmentId : null); - if (payload.attachmentId !== null) return; + if ( + payload.type !== "session_demand" || + typeof payload.attachmentId !== "string" || + typeof payload.attached !== "boolean" + ) { + return; + } + demands.push({ attachmentId: payload.attachmentId, attached: payload.attached }); + if (payload.attached) return; queueMicrotask(() => coordinator.emit({ type: "message", @@ -112,7 +121,8 @@ describe("Session worker lifecycle failures", () => { token: "worker-token", sessionKey: metadata.path, requestId: payload.requestId, - attachmentId: null, + attachmentId: payload.attachmentId, + attached: false, }, }), ); @@ -122,8 +132,8 @@ describe("Session worker lifecycle failures", () => { await vi.advanceTimersByTimeAsync(5_000); await attaching; expect(demands).toHaveLength(2); - expect(demands[0]).toEqual(expect.any(String)); - expect(demands[1]).toBeNull(); + expect(demands[0]).toMatchObject({ attachmentId: expect.any(String), attached: true }); + expect(demands[1]).toEqual({ attachmentId: demands[0]!.attachmentId, attached: false }); workers.detach(); }); @@ -133,7 +143,7 @@ describe("Session worker lifecycle failures", () => { const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.createHarness(metadata, BACKGROUND_CONTEXT); + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); coordinator.onSend = () => {}; const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("worker was terminated"); @@ -164,7 +174,7 @@ describe("Session worker lifecycle failures", () => { describe("Session worker operations", () => { test("correlates prompt results to the worker generation and attachment", async () => { - const { coordinator, workers, handle, release } = await createAttachedWorker(); + const { coordinator, workers, attachment, release } = await createAttachedWorker(); coordinator.onSend = (peerId, payload) => { if (payload.type !== "operation") return; const scope = asObject(payload.scope); @@ -190,7 +200,7 @@ describe("Session worker operations", () => { }); }; - await expect(handle.prompt(["Hello"], BACKGROUND_CONTEXT)).resolves.toEqual({ + await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).resolves.toEqual({ ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, }); @@ -206,7 +216,7 @@ describe("Session worker operations", () => { }); test("rejects a correlated response with mismatched worker identity", async () => { - const { coordinator, workers, handle, release } = await createAttachedWorker(); + const { coordinator, workers, attachment, release } = await createAttachedWorker(); coordinator.onSend = (peerId, payload) => { if (payload.type !== "operation") return; queueMicrotask(() => @@ -231,13 +241,13 @@ describe("Session worker operations", () => { ); }; - await expect(handle.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/mismatched operation response/); + await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/mismatched operation response/); workers.detach(); await release(); }); test("rejects a null request scope", async () => { - const { coordinator, workers, handle, release } = await createAttachedWorker(); + const { coordinator, workers, attachment, release } = await createAttachedWorker(); coordinator.onSend = (peerId, payload) => { if (payload.type !== "operation") return; queueMicrotask(() => @@ -262,15 +272,15 @@ describe("Session worker operations", () => { ); }; - await expect(handle.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/invalid operation response/); + await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/invalid operation response/); workers.detach(); await release(); }); test("rejects pending prompts on replacement without stopping the worker", async () => { - const { coordinator, workers, handle } = await createAttachedWorker(); + const { coordinator, workers, attachment } = await createAttachedWorker(); coordinator.onSend = () => {}; - const prompting = handle.prompt(["Hello"], BACKGROUND_CONTEXT); + const prompting = attachment.prompt(["Hello"], BACKGROUND_CONTEXT); workers.detach(); await expect(prompting).rejects.toThrow(/replaced during a worker operation/); diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 14f85b932..29e843fdd 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -2,13 +2,14 @@ Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol. -Protocol version `1` currently contains the first Session-operation slice: +Protocol version `2` contains the first routed Session-operation slice: - a version handshake that identifies the logical `serverId`; +- explicit server and Session request targets; - a service RPC manifest with Session discovery, attachment, prompting, and lane-watch operations; - correlated responses, attachment-scoped lane events, 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 durable `SessionMetadata`. `create()` creates durable metadata for a working directory without opening a Harness; its ID is optional. `attach()` exclusively binds that Session to the client connection and returns only its `sessionId`. `prompt()` targets an explicitly identified Session attached to the requesting connection. The real `Session` and `AgentHarness` remain hosted by the server. Disconnecting releases the attachment after accepted work settles. +The manifest generates typed client methods and validated endpoint dispatch. The transport carries contract-agnostic `{ serviceId, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. `list()` and `create()` return presentation-safe `SessionSummary` values containing no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. `attach()` creates or selects one presentation attachment and returns its Session and attachment IDs. `prompt()` targets that exact live attachment. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles. `PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances. diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index 61ad4c226..5b77e63bb 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -1,6 +1,7 @@ import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; import { LaneEventSchema, LaneSnapshotSchema, PromptArgumentsSchema, RunResultSchema } from "./harness.ts"; +import { JsonValueSchema } from "./json-value.ts"; import { createRpcCallSchema, createRpcResultSchema, @@ -12,7 +13,7 @@ import { type RpcResultUnion, } from "./rpc.ts"; -export const PROTOCOL_VERSION = 1 as const; +export const PROTOCOL_VERSION = 2 as const; const IdSchema = Type.String({ minLength: 1 }); const TimestampSchema = Type.Integer({ minimum: 0 }); @@ -30,20 +31,16 @@ export function isServerId(value: unknown): value is ServerId { return Check(ServerIdSchema, value); } -/** Durable Session metadata exposed by the wire protocol. */ -export const SessionMetadataSchema = StrictObject({ - id: SessionIdSchema, +/** Presentation-safe Session directory record. */ +export const SessionSummarySchema = StrictObject({ + serverId: ServerIdSchema, + sessionId: SessionIdSchema, createdAt: TimestampSchema, - storageVersion: Type.Integer({ minimum: 1 }), - cwd: Type.Optional(Type.String({ minLength: 1 })), - parentSessionId: Type.Optional(SessionIdSchema), - legacyParentSessionPath: Type.Optional(Type.String({ minLength: 1 })), }); -export type SessionMetadata = Static; +export type SessionSummary = Static; export const SessionCreateOptionsSchema = StrictObject({ id: Type.Optional(SessionIdSchema), - cwd: Type.String({ minLength: 1 }), }); export type SessionCreateOptions = Static; @@ -51,30 +48,30 @@ export type SessionCreateOptions = Static; export const ServiceRpc = defineRpc({ list: { args: Type.Tuple([]), - result: Type.Array(SessionMetadataSchema), + result: Type.Array(SessionSummarySchema), }, create: { args: Type.Tuple([SessionCreateOptionsSchema]), - result: SessionMetadataSchema, + result: SessionSummarySchema, }, attach: { args: Type.Tuple([SessionIdSchema]), - result: StrictObject({ sessionId: SessionIdSchema }), + result: StrictObject({ sessionId: SessionIdSchema, attachmentId: IdSchema }), }, prompt: { - args: Type.Tuple([SessionIdSchema, PromptArgumentsSchema]), + args: Type.Tuple([PromptArgumentsSchema]), result: RunResultSchema, }, watch: { - args: Type.Tuple([SessionIdSchema]), + args: Type.Tuple([]), result: StrictObject({ watchId: IdSchema, snapshot: LaneSnapshotSchema }), }, startWatch: { - args: Type.Tuple([SessionIdSchema, IdSchema]), + args: Type.Tuple([IdSchema]), result: StrictObject({ watchId: IdSchema }), }, stopWatch: { - args: Type.Tuple([SessionIdSchema, IdSchema]), + args: Type.Tuple([IdSchema]), result: StrictObject({ watchId: IdSchema }), }, }); @@ -87,17 +84,39 @@ export type ServiceRpcResultUnion = RpcResultUnion; export const ServiceRpcCallSchema = Type.Unsafe(createRpcCallSchema(ServiceRpc)); export const ServiceRpcResultSchema = Type.Unsafe(createRpcResultSchema(ServiceRpc)); -/** Untyped transport call. Domain facades own method typing and result decoding. */ +/** Contract-agnostic service/member invocation carried by the transport. */ export const ProtocolRpcCallSchema = StrictObject({ - method: Type.String({ minLength: 1 }), - args: Type.Unknown(), + serviceId: Type.String({ minLength: 1 }), + member: Type.String({ minLength: 1 }), + args: Type.Array(JsonValueSchema), }); export type ProtocolRpcCall = Static; export type ProtocolRpcResult = unknown; const ProtocolRpcResultSchema = Type.Unknown(); -export function isServiceRpcCall(call: ProtocolRpcCall): call is ServiceRpcCall { - return Check(ServiceRpcCallSchema, call); +const ServiceRpcAddresses = { + list: { serviceId: "session-directory", member: "list" }, + create: { serviceId: "session-management", member: "create" }, + attach: { serviceId: "session-management", member: "attach" }, + prompt: { serviceId: "chat", member: "prompt" }, + watch: { serviceId: "transcript", member: "watch" }, + startWatch: { serviceId: "transcript", member: "startWatch" }, + stopWatch: { serviceId: "transcript", member: "stopWatch" }, +} as const satisfies Record; + +/** Translate the current built-in contract to its generic service envelope. */ +export function encodeServiceRpcCall(call: ServiceRpcCall): ProtocolRpcCall { + return { ...ServiceRpcAddresses[call.method], args: call.args }; +} + +/** Validate and decode one generic envelope against the current built-in contract. */ +export function decodeServiceRpcCall(call: ProtocolRpcCall): ServiceRpcCall | undefined { + for (const [method, address] of Object.entries(ServiceRpcAddresses)) { + if (call.serviceId !== address.serviceId || call.member !== address.member) continue; + const candidate = { method, args: call.args }; + return Check(ServiceRpcCallSchema, candidate) ? (candidate as ServiceRpcCall) : undefined; + } + return undefined; } export const ProtocolErrorCodeSchema = Type.Union([ @@ -105,7 +124,6 @@ export const ProtocolErrorCodeSchema = Type.Union([ Type.Literal("wrong_server"), Type.Literal("session_not_found"), Type.Literal("session_ambiguous"), - Type.Literal("session_in_use"), Type.Literal("session_not_attached"), Type.Literal("watch_not_found"), Type.Literal("watch_in_use"), @@ -113,13 +131,6 @@ export const ProtocolErrorCodeSchema = Type.Union([ Type.Literal("server_draining"), Type.Literal("invalid_request"), Type.Literal("cancelled"), - Type.Literal("session_invalid_lane"), - Type.Literal("session_lane_exists"), - Type.Literal("session_unknown_target"), - Type.Literal("session_pending_message"), - Type.Literal("session_invariant"), - Type.Literal("mutation_not_found"), - Type.Literal("mutation_expired"), Type.Literal("internal_error"), ]); export const ProtocolErrorSchema = StrictObject({ @@ -136,16 +147,43 @@ export const ClientHelloSchema = StrictObject({ }); export type ClientHello = Static; +/** A server-wide call, fenced to one logical server. */ +export const ServerTargetSchema = StrictObject({ + serverId: ServerIdSchema, +}); +export type ServerTarget = Static; + +/** Durable Session identity, unique within the addressed logical server. */ +export const SessionAddressSchema = StrictObject({ + serverId: ServerIdSchema, + sessionId: SessionIdSchema, +}); +export type SessionAddress = Static; + +/** A session call, fenced to one logical server, durable session, and live attachment. */ +export const SessionTargetSchema = StrictObject({ + serverId: ServerIdSchema, + sessionId: SessionIdSchema, + attachmentId: IdSchema, +}); +export type SessionTarget = Static; +export const RpcTargetSchema = Type.Union([ServerTargetSchema, SessionTargetSchema]); +export type RpcTarget = Static; + +export function isSessionTarget(target: RpcTarget): target is SessionTarget { + return "sessionId" in target; +} + export const RequestEnvelopeSchema = StrictObject({ type: Type.Literal("request"), id: IdSchema, - serverId: ServerIdSchema, + target: RpcTargetSchema, call: ProtocolRpcCallSchema, }); export const CancelEnvelopeSchema = StrictObject({ type: Type.Literal("cancel"), id: IdSchema, - serverId: ServerIdSchema, + target: RpcTargetSchema, }); export type RequestEnvelope = Static; export type CancelEnvelope = Static; diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 9f6f2404d..d6d5a6766 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -7,11 +7,13 @@ import { createRpcClient, createRpcDispatcher, decodeCbor, + decodeServiceRpcCall, defineRpc, encodeCbor, encodeClientMessage, encodeFrame, encodeServerMessage, + encodeServiceRpcCall, FrameDecoder, isSupportedProtocolVersion, type JsonValue, @@ -28,8 +30,8 @@ import { type ServerMessage, ServerMessageDecoder, ServiceRpc, - type SessionMetadata, - SessionMetadataSchema, + type SessionSummary, + SessionSummarySchema, } from "../src/index.ts"; const clientHello: ClientHello = { type: "hello", version: PROTOCOL_VERSION }; @@ -39,13 +41,11 @@ const serverHello: ServerHello = { serverId: "00000000-0000-4000-8000-000000000001", }; -const metadata = { - id: "session-1", +const summary = { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", createdAt: 1, - storageVersion: 1, - cwd: "/workspace", - parentSessionId: "parent-1", -} as const satisfies SessionMetadata; +} as const satisfies SessionSummary; const laneSnapshot = { lane: "main", @@ -69,65 +69,77 @@ describe("RPC manifest", () => { calls.push(call); switch (call.method) { case "list": - return [metadata]; + return [summary]; case "create": - return metadata; + return summary; case "attach": - return { sessionId: call.args[0] }; + return { sessionId: call.args[0], attachmentId: "attachment-1" }; case "prompt": return completedRunResult; case "watch": return { watchId: "watch-1", snapshot: laneSnapshot }; case "startWatch": case "stopWatch": - return { watchId: call.args[1] }; + return { watchId: call.args[0] }; } }); - await expect(client.list()).resolves.toEqual([metadata]); - await expect(client.create({ cwd: "/workspace" })).resolves.toEqual(metadata); - await expect(client.attach("session-1")).resolves.toEqual({ sessionId: "session-1" }); - await expect(client.prompt("session-1", ["Hello"])).resolves.toEqual(completedRunResult); - await expect(client.watch("session-1")).resolves.toEqual({ watchId: "watch-1", snapshot: laneSnapshot }); - await expect(client.startWatch("session-1", "watch-1")).resolves.toEqual({ watchId: "watch-1" }); - await expect(client.stopWatch("session-1", "watch-1")).resolves.toEqual({ watchId: "watch-1" }); + await expect(client.list()).resolves.toEqual([summary]); + await expect(client.create({})).resolves.toEqual(summary); + await expect(client.attach("session-1")).resolves.toEqual({ + sessionId: "session-1", + attachmentId: "attachment-1", + }); + await expect(client.prompt(["Hello"])).resolves.toEqual(completedRunResult); + await expect(client.watch()).resolves.toEqual({ watchId: "watch-1", snapshot: laneSnapshot }); + await expect(client.startWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" }); + await expect(client.stopWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" }); expect(calls).toEqual([ { method: "list", args: [] }, - { method: "create", args: [{ cwd: "/workspace" }] }, + { method: "create", args: [{}] }, { method: "attach", args: ["session-1"] }, - { method: "prompt", args: ["session-1", ["Hello"]] }, - { method: "watch", args: ["session-1"] }, - { method: "startWatch", args: ["session-1", "watch-1"] }, - { method: "stopWatch", args: ["session-1", "watch-1"] }, + { method: "prompt", args: [["Hello"]] }, + { method: "watch", args: [] }, + { method: "startWatch", args: ["watch-1"] }, + { method: "stopWatch", args: ["watch-1"] }, ]); }); + test("maps built-in contracts onto generic service/member envelopes", () => { + const encoded = encodeServiceRpcCall({ method: "attach", args: ["session-1"] }); + expect(encoded).toEqual({ + serviceId: "session-management", + member: "attach", + args: ["session-1"], + }); + expect(decodeServiceRpcCall(encoded)).toEqual({ method: "attach", args: ["session-1"] }); + expect(decodeServiceRpcCall({ serviceId: "session-management", member: "attach", args: [] })).toBeUndefined(); + expect(decodeServiceRpcCall({ serviceId: "unknown", member: "method", args: [] })).toBeUndefined(); + }); + test("dispatches only methods and values allowed by the manifest", async () => { const dispatch = createRpcDispatcher(ServiceRpc, { - list: () => [metadata], - create: () => metadata, - attach: (_context, sessionId) => ({ sessionId }), + list: () => [summary], + create: () => summary, + attach: (_context, sessionId) => ({ sessionId, attachmentId: "attachment-1" }), prompt: () => completedRunResult, watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }), - startWatch: (_context, _sessionId, watchId) => ({ watchId }), - stopWatch: (_context, _sessionId, watchId) => ({ watchId }), + startWatch: (_context, watchId) => ({ watchId }), + stopWatch: (_context, watchId) => ({ watchId }), }); - await expect(dispatch({ method: "list", args: [] }, undefined)).resolves.toEqual([metadata]); - await expect(dispatch({ method: "create", args: [{ cwd: "/workspace" }] }, undefined)).resolves.toEqual(metadata); + await expect(dispatch({ method: "list", args: [] }, undefined)).resolves.toEqual([summary]); + await expect(dispatch({ method: "create", args: [{}] }, undefined)).resolves.toEqual(summary); await expect(dispatch({ method: "attach", args: ["session-1"] }, undefined)).resolves.toEqual({ sessionId: "session-1", + attachmentId: "attachment-1", }); - await expect(dispatch({ method: "prompt", args: ["session-1", ["Hello"]] }, undefined)).resolves.toEqual( - completedRunResult, - ); - await expect(dispatch({ method: "watch", args: ["session-1"] }, undefined)).resolves.toEqual({ + await expect(dispatch({ method: "prompt", args: [["Hello"]] }, undefined)).resolves.toEqual(completedRunResult); + await expect(dispatch({ method: "watch", args: [] }, undefined)).resolves.toEqual({ watchId: "watch-1", snapshot: laneSnapshot, }); await expect(dispatch({ method: "attach", args: [] } as never, undefined)).rejects.toThrow(/Invalid arguments/); - await expect(dispatch({ method: "prompt", args: ["session-1", []] } as never, undefined)).rejects.toThrow( - /Invalid arguments/, - ); + await expect(dispatch({ method: "prompt", args: [[]] } as never, undefined)).rejects.toThrow(/Invalid arguments/); }); test("rejects invalid results on both client and dispatcher boundaries", async () => { @@ -136,17 +148,17 @@ describe("RPC manifest", () => { const dispatch = createRpcDispatcher(ServiceRpc, { list: () => [{ id: "session-1" }], - create: () => metadata, - attach: (_context: undefined, sessionId: string) => ({ sessionId }), + create: () => summary, + attach: (_context: undefined, sessionId: string) => ({ sessionId, attachmentId: "attachment-1" }), prompt: () => completedRunResult, watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }), - startWatch: (_context: undefined, _sessionId: string, watchId: string) => ({ watchId }), - stopWatch: (_context: undefined, _sessionId: string, watchId: string) => ({ watchId }), + startWatch: (_context: undefined, watchId: string) => ({ watchId }), + stopWatch: (_context: undefined, watchId: string) => ({ watchId }), } as never); await expect(dispatch({ method: "list", args: [] }, undefined)).rejects.toThrow(/Invalid result.*list/); const invalidPromptClient = createRpcClient(ServiceRpc, async () => ({ ok: true, value: {} })); - await expect(invalidPromptClient.prompt("session-1", ["Hello"])).rejects.toThrow(/Invalid result.*prompt/); + await expect(invalidPromptClient.prompt(["Hello"])).rejects.toThrow(/Invalid result.*prompt/); }); test("rejects empty manifests instead of creating unusable RPC clients", () => { @@ -155,10 +167,10 @@ describe("RPC manifest", () => { }); describe("protocol validation", () => { - test("negotiates protocol version 1", () => { - expect(PROTOCOL_VERSION).toBe(1); - expect(isSupportedProtocolVersion(1)).toBe(true); - expect(isSupportedProtocolVersion(2)).toBe(false); + test("negotiates protocol version 2", () => { + expect(PROTOCOL_VERSION).toBe(2); + expect(isSupportedProtocolVersion(2)).toBe(true); + expect(isSupportedProtocolVersion(1)).toBe(false); expect(isSupportedProtocolVersion(2.5)).toBe(false); }); @@ -196,42 +208,49 @@ describe("protocol validation", () => { const list: ClientMessage = { type: "request", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list", args: [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory", member: "list", args: [] }, }; const create: ClientMessage = { type: "request", id: "request-2", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "create", args: [{ cwd: "/workspace" }] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-management", member: "create", args: [{}] }, }; const attach: ClientMessage = { type: "request", id: "request-3", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "attach", args: ["session-1"] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-management", member: "attach", args: ["session-1"] }, }; const prompt: ClientMessage = { type: "request", id: "request-4", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "prompt", args: ["session-1", ["Hello"]] }, + target: { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + attachmentId: "attachment-1", + }, + call: { serviceId: "chat", member: "prompt", args: [["Hello"]] }, }; expect(parseClientMessage(list)).toEqual(list); expect(parseClientMessage(create)).toEqual(create); expect(parseClientMessage(attach)).toEqual(attach); expect(parseClientMessage(prompt)).toEqual(prompt); - expect(parseClientMessage({ ...create, call: { method: "create", args: [{ cwd: "" }] } })).toMatchObject({ - call: { method: "create" }, + expect(parseClientMessage({ ...create, call: { ...create.call, args: [{ cwd: "" }] } })).toMatchObject({ + call: { serviceId: "session-management", member: "create" }, }); - expect(parseClientMessage({ ...attach, call: { method: "attach", args: [] } })).toMatchObject({ - call: { method: "attach", args: [] }, + expect(parseClientMessage({ ...attach, call: { ...attach.call, args: [] } })).toMatchObject({ + call: { serviceId: "session-management", member: "attach", args: [] }, }); - expect(parseClientMessage({ ...attach, call: { method: "unknown", args: { arbitrary: true } } })).toMatchObject({ - call: { method: "unknown", args: { arbitrary: true } }, - }); - expect(parseClientMessage({ ...prompt, call: { method: "prompt", args: ["session-1", []] } })).toMatchObject({ - call: { method: "prompt" }, + expect( + parseClientMessage({ + ...attach, + call: { serviceId: "unknown", member: "method", args: [{ arbitrary: true }] }, + }), + ).toMatchObject({ call: { serviceId: "unknown", member: "method" } }); + expect(parseClientMessage({ ...prompt, call: { ...prompt.call, args: ["session-1", []] } })).toMatchObject({ + call: { serviceId: "chat", member: "prompt" }, }); }); @@ -239,7 +258,7 @@ describe("protocol validation", () => { const cancel: ClientMessage = { type: "cancel", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", + target: { serverId: "00000000-0000-4000-8000-000000000001" }, }; expect(parseClientMessage(cancel)).toEqual(cancel); expect(() => parseClientMessage({ ...cancel, id: "" })).toThrow(ProtocolValidationError); @@ -320,14 +339,14 @@ describe("protocol validation", () => { expect(Check(PromptArgumentsSchema, promptArguments)).toBe(false); }); - test("keeps Session metadata validation at the typed service boundary", () => { - expect(Check(SessionMetadataSchema, metadata)).toBe(true); - expect(Check(SessionMetadataSchema, { id: "session-1", createdAt: 1 })).toBe(false); + test("keeps presentation-safe Session summary validation at the typed service boundary", () => { + expect(Check(SessionSummarySchema, summary)).toBe(true); + expect(Check(SessionSummarySchema, { ...summary, cwd: "/private" })).toBe(false); const message: ServerMessage = { type: "response", id: "request-1", ok: true, - result: [metadata], + result: [summary], }; expect(parseServerMessage(message)).toEqual(message); }); @@ -482,20 +501,12 @@ describe("protocol validation", () => { test.each([ "wrong_server", "session_not_found", - "session_in_use", "session_not_attached", "watch_not_found", "watch_in_use", "not_supported", "server_draining", "cancelled", - "session_invalid_lane", - "session_lane_exists", - "session_unknown_target", - "session_pending_message", - "session_invariant", - "mutation_not_found", - "mutation_expired", "internal_error", ] as const)("accepts the %s error code", (code) => { const message: ServerMessage = { @@ -535,8 +546,8 @@ describe("validated framed protocol APIs", () => { const request: ClientMessage = { type: "request", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list", args: [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory", member: "list", args: [] }, }; const first = encodeClientMessage(clientHello); const second = encodeClientMessage(request); diff --git a/packages/server/README.md b/packages/server/README.md index 62bf2431f..a6b47beb4 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -2,27 +2,27 @@ Experimental local server for the new durable Session and Agent Harness interfaces. -The current slice supports Session discovery, creation, exclusive attachment, prompting, and optional main-lane observation. A host that implements `HostedHarnessHandle.watch()` supplies an authoritative snapshot plus buffered events. The server owns the attachment's single watch ID, starts delivery only after the client has received the snapshot, and removes the watch when the connection attachment is released. +The current slice supports Session discovery, creation, multi-presentation attachment, prompting, and optional main-lane observation. `RoutedSessionHandle.attachClient()` returns a presentation-scoped capability whose optional `watch()` supplies an authoritative snapshot plus buffered events. The server owns each attachment's single watch ID, starts delivery only after the client has received the snapshot, and removes the watch when that attachment is released. -- `list` calls the host's Session catalog without opening sessions. -- `create` asks the host to persist an optional Session ID and working directory without opening a Harness. -- `attach` finds the requested metadata, passes it to the host, and retains the returned Harness handle in the server. -- `prompt` executes one serializable Harness prompt through the requesting client's attached handle. +- `list` calls the host's private Session catalog without opening Sessions and projects presentation-safe summaries. +- `create` asks the host to persist an optional Session ID; private workspace and working-directory data are server-derived. +- `attach` finds the requested metadata, passes it to the host, and retains the returned routed Session handle in the server. +- `prompt` executes one serializable prompt through the requesting presentation's attachment capability. - `watch`, `startWatch`, and `stopWatch` provide snapshot-first lane observation when supported by the host. -A Session permits one attached client connection at a time. Repeating `attach` from that connection is idempotent; another connection receives `session_in_use`. Prompting requires that exact connection to own the targeted attachment. Losing the connection rejects the local response but releases its attachment lease only after accepted prompts settle. The host decides when zero client demand and Harness activity permit worker retirement. Server shutdown closes every hosted Harness, releasing its Session writer ownership. +A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId`. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted prompts settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership. ```ts import { randomUUID } from "node:crypto"; import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core"; import { - type HostedHarnessHandle, + type RoutedSessionHandle, type PiServerHost, } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; async function startServer( - createHarnessForSession: (session: Session) => Promise, + openRoutedSession: (session: Session) => Promise, ) { const sessions = new MemorySessionRepo(); const host: PiServerHost = { @@ -37,10 +37,10 @@ async function startServer( } }, }, - async createHarness(metadata) { + async openSession(metadata) { const session = await sessions.open(metadata); try { - return await createHarnessForSession(session); + return await openRoutedSession(session); } catch (error) { try { await session.close(); @@ -65,7 +65,7 @@ async function startServer( } ``` -Applications supply a Session catalog and a Harness factory. The catalog lists and creates durable metadata; the host receives the repository's concrete metadata and owns opening the Session, creating the Harness, and cleaning up failed Harness creation. This permits the host to perform those operations in a worker process without passing an open JavaScript Session across processes. +Applications supply a private Session catalog and a routed Session factory. The server projects catalog records to summaries before transport; the host receives the repository's concrete metadata and owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary. `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. diff --git a/packages/server/src/connection.ts b/packages/server/src/connection.ts index 673af8db4..3eadb8ede 100644 --- a/packages/server/src/connection.ts +++ b/packages/server/src/connection.ts @@ -1,4 +1,4 @@ -import type { ClientMessageDecoder } from "@earendil-works/pi-protocol"; +import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol"; import type { MaybePromise } from "./types.ts"; @@ -26,7 +26,7 @@ export interface ConnectionState { disconnected: boolean; handshake?: Promise; handshakeTimeout: NodeJS.Timeout; - activeRequests: Map; + activeRequests: Map; } export function isTerminalConnection(state: ConnectionState): boolean { diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 913618267..c7cca4882 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -5,14 +5,11 @@ type PiServerOperationErrorCode = Extract< | "wrong_server" | "session_not_found" | "session_ambiguous" - | "session_in_use" | "session_not_attached" | "watch_not_found" | "watch_in_use" | "not_supported" | "server_draining" - | "mutation_not_found" - | "mutation_expired" >; export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error"; @@ -49,13 +46,6 @@ export class SessionAmbiguousError extends PiServerError { } } -export class SessionInUseError extends PiServerError { - constructor() { - super("session_in_use", "Session is attached to another client"); - this.name = "SessionInUseError"; - } -} - export class SessionNotAttachedError extends PiServerError { constructor() { super("session_not_attached", "Session is not attached to this client"); diff --git a/packages/server/src/remote-session-manager.ts b/packages/server/src/remote-session-manager.ts deleted file mode 100644 index e7ffcf4ff..000000000 --- a/packages/server/src/remote-session-manager.ts +++ /dev/null @@ -1,564 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { - AgentMessage, - BranchScan, - Context, - EntryQuery, - EntryType, - JsonValue, - LaneConfiguration, - ListReadOptions, - Session, - SessionMetadata, - SessionMutator, - StorageBranchScan, - Value, - ValueList, - Write, -} from "@earendil-works/pi-agent-core"; -import { isJsonValue, ProtocolValidationError } from "@earendil-works/pi-protocol"; -import { - NotSupportedError, - PiServerError, - ServerDrainingError, - SessionAmbiguousError, - SessionInUseError, - SessionNotFoundError, -} from "./errors.ts"; -import type { PiServerHost } from "./types.ts"; - -export const DEFAULT_REMOTE_MUTATION_LEASE_MS = 30_000; - -export class RemoteMutationNotFoundError extends PiServerError { - constructor() { - super("mutation_not_found", "Remote Session mutation was not found"); - this.name = "RemoteMutationNotFoundError"; - } -} - -export class RemoteMutationExpiredError extends PiServerError { - constructor() { - super("mutation_expired", "Remote Session mutation lease expired"); - this.name = "RemoteMutationExpiredError"; - } -} - -interface OpenSession { - readonly client: object; - readonly handle: string; - readonly session: Session; - readonly mutations: Map; - readonly expiredMutations: Set; - closing?: Promise; -} - -interface RemoteMutation { - readonly id: string; - completion: Promise; - readonly release: () => void; - mutator?: SessionMutator; - lease?: NodeJS.Timeout; - state: "opening" | "active" | "finishing" | "expired"; -} - -interface RemoteSessionManagerOptions { - readonly host: PiServerHost; - readonly mutationLeaseMs: number; - readonly isSessionUnavailable?: (sessionId: string) => boolean; -} - -/** Connection-scoped remote Session capabilities dispatched through untyped RPC method names. */ -export class RemoteSessionManager { - readonly #options: RemoteSessionManagerOptions; - readonly #sessionsByHandle = new Map>(); - readonly #sessionsById = new Map>(); - readonly #sessionsByClient = new Map>>(); - readonly #closingSessions = new Set>(); - #openTail: Promise = Promise.resolve(); - #closing = false; - - constructor(options: RemoteSessionManagerOptions) { - this.#options = options; - } - - hasSession(sessionId: string): boolean { - return this.#sessionsById.has(sessionId); - } - - invoke(method: string, args: unknown, client: object, context: Context): Promise { - if (method === "session.open") - return this.#open(expectString(expectArgs(args).sessionId, "sessionId"), client, context); - const params = expectArgs(args); - const owner = this.#requireOwner(expectString(params.handle, "handle"), client); - switch (method) { - case "session.close": - return this.#closeOwner(owner, context).then(() => null); - case "session.getEntries": - return owner.session - .getEntries(expectStringArray(params.ids, "ids"), context) - .then((entries) => [...entries]); - case "session.getValue": - return owner.session.getValue(expectValue(params.address), context).then((value) => value ?? null); - case "session.scanValues": - return owner.session.scanValues(expectValue(params.prefix), context); - case "session.readList": - return owner.session.readList(expectList(params.address), optionalOptions(params.options), context); - case "session.scanBranch": - return owner.session.scanBranch(expectStorageBranchScan(params.query), context); - case "session.createLane": - return owner.session - .createLane( - expectString(params.name, "name"), - expectNullableString(params.at, "at"), - expectObject(params.configuration, "configuration") as unknown as LaneConfiguration, - undefined, - context, - ) - .then(() => null); - case "session.mutation.begin": - return this.#beginMutation(owner, expectString(params.mutationLane, "mutationLane"), context); - case "session.mutation.finish": - return this.#finishMutation(owner, expectString(params.mutationId, "mutationId")).then(() => null); - case "session.mutation.getEntries": { - const mutation = this.#requireMutation(owner, params); - return mutation - .mutator!.getEntries(expectStringArray(params.ids, "ids"), context) - .then((entries) => [...entries]); - } - case "session.mutation.getValue": { - const mutation = this.#requireMutation(owner, params); - return mutation.mutator!.getValue(expectValue(params.address), context).then((value) => value ?? null); - } - case "session.mutation.scanValues": { - const mutation = this.#requireMutation(owner, params); - return mutation.mutator!.scanValues(expectValue(params.prefix), context); - } - case "session.mutation.readList": { - const mutation = this.#requireMutation(owner, params); - return mutation.mutator!.readList(expectList(params.address), optionalOptions(params.options), context); - } - case "session.mutation.scanBranch": { - const mutation = this.#requireMutation(owner, params); - return mutation.mutator!.scanBranch(expectStorageBranchScan(params.query), context); - } - case "session.mutation.commit": { - const mutation = this.#requireMutation(owner, params); - if (!Array.isArray(params.writes)) throw new ProtocolValidationError("writes must be an array"); - return mutation.mutator!.commit(params.writes as Write[], context); - } - } - - const lane = expectString(params.lane, "lane"); - const tree = owner.session.view(lane); - switch (method) { - case "session.tree.getLeafId": - return tree.getLeafId(context); - case "session.tree.getEntry": - return tree.getEntry(expectString(params.id, "id"), context).then((entry) => entry ?? null); - case "session.tree.getStats": - return tree.getStats(context); - case "session.tree.getValue": - return tree.getValue(expectValue(params.address), context).then((value) => value ?? null); - case "session.tree.scanValues": - return tree.scanValues(expectValue(params.prefix), context); - case "session.tree.readList": - return tree.readList(expectList(params.address), optionalOptions(params.options), context); - case "session.tree.setValue": - return tree.setValue(expectValue(params.address), required(params, "next"), context).then(() => null); - case "session.tree.deleteValue": - return tree.deleteValue(expectValue(params.address), context).then(() => null); - case "session.tree.appendList": - return tree.appendList(expectList(params.address), required(params, "element"), context).then(() => null); - case "session.tree.deleteList": - return tree.deleteList(expectList(params.address), context).then(() => null); - case "session.tree.getName": - return tree.getName(context).then((name) => name ?? null); - case "session.tree.setName": - return tree.setName(expectOptionalString(params.name, "name"), context).then(() => null); - case "session.tree.getLabel": - return tree.getLabel(expectString(params.targetId, "targetId"), context).then((label) => label ?? null); - case "session.tree.setLabel": - return tree - .setLabel( - expectString(params.targetId, "targetId"), - expectOptionalString(params.label, "label"), - context, - ) - .then(() => null); - case "session.tree.findEntries": - return tree.findEntries(optionalEntryQuery(params.query), context); - case "session.tree.findEntry": - return tree.findEntry(optionalEntryQuery(params.query), context).then((entry) => entry ?? null); - case "session.tree.findEntriesOnBranch": - return tree.findEntriesOnBranch(optionalBranchScan(params.query), context); - case "session.tree.findEntryOnBranch": - return tree.findEntryOnBranch(optionalBranchScan(params.query), context).then((entry) => entry ?? null); - case "session.tree.appendMessage": - return tree.appendMessage(expectObject(params.message, "message") as unknown as AgentMessage, context); - case "session.tree.appendCustomEntry": - return tree.appendCustomEntry( - expectString(params.customType, "customType"), - optionalJsonValue(params, "data"), - context, - ); - default: - return Promise.reject(new ProtocolValidationError(`Unknown RPC method ${method}`)); - } - } - - async disconnect(client: object, context: Context): Promise { - const sessions = [...(this.#sessionsByClient.get(client) ?? [])]; - const results = await Promise.allSettled(sessions.map((session) => this.#closeOwner(session, context))); - const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Failed to close remote Sessions after disconnect"); - } - - async close(context: Context): Promise { - this.#closing = true; - await this.#openTail.catch(() => {}); - const sessions = [...this.#sessionsByHandle.values()]; - const closing = sessions.map((session) => this.#closeOwner(session, context)); - const results = await Promise.allSettled([...this.#closingSessions, ...closing]); - const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Failed to close remote Sessions"); - } - - #open(sessionId: string, client: object, context: Context): Promise { - return this.#serializeOpen(async () => { - if (this.#closing) throw new ServerDrainingError(); - const existing = [...(this.#sessionsByClient.get(client) ?? [])].find( - (candidate) => candidate.session.metadata.id === sessionId, - ); - if (existing) return { handle: existing.handle, metadata: toProtocolMetadata(existing.session.metadata) }; - if (this.#sessionsById.has(sessionId) || this.#options.isSessionUnavailable?.(sessionId)) { - throw new SessionInUseError(); - } - const open = this.#options.host.sessions.open; - if (open === undefined) throw new NotSupportedError("Host does not support remote Sessions"); - const matches = (await this.#options.host.sessions.list(context)).filter( - (metadata) => metadata.id === sessionId, - ); - if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); - if (matches.length > 1) throw new SessionAmbiguousError(); - const session = await open(matches[0]!, context); - if (this.#closing) { - await session.close(context); - throw new ServerDrainingError(); - } - const owner: OpenSession = { - client, - handle: randomUUID(), - session, - mutations: new Map(), - expiredMutations: new Set(), - }; - this.#sessionsByHandle.set(owner.handle, owner); - this.#sessionsById.set(sessionId, owner); - let clientSessions = this.#sessionsByClient.get(client); - if (clientSessions === undefined) { - clientSessions = new Set(); - this.#sessionsByClient.set(client, clientSessions); - } - clientSessions.add(owner); - return { handle: owner.handle, metadata: toProtocolMetadata(session.metadata) }; - }); - } - - #serializeOpen(operation: () => Promise): Promise { - const result = this.#openTail.catch(() => {}).then(operation); - this.#openTail = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - #requireOwner(handle: string, client: object): OpenSession { - const owner = this.#sessionsByHandle.get(handle); - if (owner === undefined || owner.client !== client || owner.closing !== undefined) { - throw new SessionNotFoundError("Remote Session handle was not found"); - } - return owner; - } - - async #closeOwner(owner: OpenSession, context: Context): Promise { - if (owner.closing !== undefined) return owner.closing; - const closing = (async () => { - this.#sessionsByHandle.delete(owner.handle); - if (this.#sessionsById.get(owner.session.metadata.id) === owner) { - this.#sessionsById.delete(owner.session.metadata.id); - } - const clientSessions = this.#sessionsByClient.get(owner.client); - clientSessions?.delete(owner); - if (clientSessions?.size === 0) this.#sessionsByClient.delete(owner.client); - const mutations = [...owner.mutations.values()]; - for (const mutation of mutations) mutation.release(); - const errors = (await Promise.allSettled(mutations.map((mutation) => mutation.completion))).flatMap( - (result) => (result.status === "rejected" ? [result.reason] : []), - ); - owner.expiredMutations.clear(); - try { - await owner.session.close(context); - } catch (error) { - errors.push(error); - } - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Failed to close remote Session"); - })(); - owner.closing = closing; - this.#closingSessions.add(closing); - void closing.finally(() => this.#closingSessions.delete(closing)).catch(() => {}); - return closing; - } - - async #beginMutation( - owner: OpenSession, - lane: string, - context: Context, - ): Promise<{ mutationId: string }> { - const id = randomUUID(); - let resolveEntered!: () => void; - let rejectEntered!: (error: unknown) => void; - const entered = new Promise((resolve, reject) => { - resolveEntered = resolve; - rejectEntered = reject; - }); - let resolveRelease!: () => void; - const released = new Promise((resolve) => { - resolveRelease = resolve; - }); - let releasedOnce = false; - const release = (): void => { - if (releasedOnce) return; - releasedOnce = true; - resolveRelease(); - }; - const resource: RemoteMutation = { - id, - completion: Promise.resolve(), - release, - state: "opening", - }; - owner.mutations.set(id, resource); - const completion = owner.session - .mutate( - lane, - async (mutator) => { - if (resource.state !== "opening") return; - resource.mutator = mutator; - resource.state = "active"; - resource.lease = setTimeout(() => { - if (resource.state !== "active") return; - resource.state = "expired"; - owner.expiredMutations.add(resource.id); - resource.release(); - }, this.#options.mutationLeaseMs); - resource.lease.unref(); - resolveEntered(); - await released; - }, - context, - ) - .catch((error: unknown) => { - rejectEntered(error); - throw error; - }) - .finally(() => { - if (resource.lease) clearTimeout(resource.lease); - owner.mutations.delete(id); - }); - resource.completion = completion; - void completion.catch(() => {}); - - const signal = context.abortSignal; - let onAbort: (() => void) | undefined; - const aborted = - signal === undefined - ? undefined - : new Promise((_resolve, reject) => { - onAbort = () => { - resource.state = "finishing"; - resource.release(); - reject(abortError(signal)); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - try { - if (signal?.aborted) throw abortError(signal); - await (aborted === undefined ? entered : Promise.race([entered, aborted])); - if (signal?.aborted) throw abortError(signal); - return { mutationId: id }; - } catch (error) { - resource.state = "finishing"; - resource.release(); - throw error; - } finally { - if (signal !== undefined && onAbort !== undefined) signal.removeEventListener("abort", onAbort); - } - } - - #requireMutation(owner: OpenSession, params: Record): RemoteMutation { - const id = expectString(params.mutationId, "mutationId"); - const mutation = owner.mutations.get(id); - if (mutation === undefined) { - if (owner.expiredMutations.has(id)) throw new RemoteMutationExpiredError(); - throw new RemoteMutationNotFoundError(); - } - if (mutation.state === "expired") throw new RemoteMutationExpiredError(); - if (mutation.state !== "active" || mutation.mutator === undefined) throw new RemoteMutationNotFoundError(); - return mutation; - } - - async #finishMutation(owner: OpenSession, id: string): Promise { - const mutation = owner.mutations.get(id); - if (mutation === undefined) { - if (owner.expiredMutations.delete(id)) throw new RemoteMutationExpiredError(); - throw new RemoteMutationNotFoundError(); - } - if (mutation.state === "expired") { - await mutation.completion; - owner.expiredMutations.delete(id); - throw new RemoteMutationExpiredError(); - } - mutation.state = "finishing"; - mutation.release(); - await mutation.completion; - } -} - -function toProtocolMetadata(metadata: SessionMetadata): SessionMetadata { - 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 }), - }; -} - -function expectArgs(value: unknown): Record { - return expectObject(value, "RPC arguments"); -} - -function expectObject(value: unknown, name: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new ProtocolValidationError(`${name} must be an object`); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new ProtocolValidationError(`${name} must be a plain object`); - } - return value as Record; -} - -function expectString(value: unknown, name: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new ProtocolValidationError(`${name} must be a non-empty string`); - } - return value; -} - -function expectStringArray(value: unknown, name: string): string[] { - if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { - throw new ProtocolValidationError(`${name} must be an array of strings`); - } - return value; -} - -function expectNullableString(value: unknown, name: string): string | null { - return value === null ? null : expectString(value, name); -} - -function expectOptionalString(value: unknown, name: string): string | undefined { - return value === null ? undefined : expectString(value, name); -} - -function expectValue(value: unknown): Value { - const address = expectObject(value, "value address"); - if (address.kind !== "value" || typeof address.namespace !== "string" || typeof address.key !== "string") { - throw new ProtocolValidationError("Invalid value address"); - } - return address as unknown as Value; -} - -function expectList(value: unknown): ValueList { - const address = expectObject(value, "list address"); - if (address.kind !== "list" || typeof address.namespace !== "string" || typeof address.key !== "string") { - throw new ProtocolValidationError("Invalid list address"); - } - return address as unknown as ValueList; -} - -function required(params: Record, key: string): unknown { - if (!Object.hasOwn(params, key)) throw new ProtocolValidationError(`${key} is required`); - return params[key]; -} - -function optionalJsonValue(params: Record, key: string): JsonValue | undefined { - if (!(key in params)) return undefined; - const value = params[key]; - if (!isJsonValue(value)) throw new ProtocolValidationError(`${key} must be a JSON value`); - return value; -} - -function optionalOptions(value: unknown): ListReadOptions | undefined { - return value === null ? undefined : (expectObject(value, "list options") as unknown as ListReadOptions); -} - -function optionalEntryQuery(value: unknown): EntryQuery | undefined { - return value === null ? undefined : (expectObject(value, "entry query") as unknown as EntryQuery); -} - -function optionalBranchScan(value: unknown): BranchScan | undefined { - return value === null ? undefined : (expectObject(value, "branch query") as unknown as BranchScan); -} - -function expectStorageBranchScan(value: unknown): StorageBranchScan { - const query = expectObject(value, "storage branch query"); - return { - start: expectString(query.start, "query.start"), - ...(query.stopAtType === undefined ? {} : { stopAtType: expectEntryType(query.stopAtType, "query.stopAtType") }), - ...(query.stopAtId === undefined ? {} : { stopAtId: expectString(query.stopAtId, "query.stopAtId") }), - ...(query.type === undefined ? {} : { type: expectEntryType(query.type, "query.type") }), - ...(query.customType === undefined ? {} : { customType: expectString(query.customType, "query.customType") }), - ...(query.order === undefined ? {} : { order: expectBranchOrder(query.order) }), - ...(query.limit === undefined ? {} : { limit: expectFiniteNumber(query.limit, "query.limit") }), - ...(query.cursor === undefined ? {} : { cursor: expectEntryCursor(query.cursor) }), - }; -} - -function expectEntryType(value: unknown, name: string): EntryType { - if (value !== "message" && value !== "compaction" && value !== "branch_summary" && value !== "custom") { - throw new ProtocolValidationError(`${name} must be a valid entry type`); - } - return value; -} - -function expectBranchOrder(value: unknown): "newestFirst" | "oldestFirst" { - if (value !== "newestFirst" && value !== "oldestFirst") { - throw new ProtocolValidationError("query.order must be newestFirst or oldestFirst"); - } - return value; -} - -function expectEntryCursor(value: unknown): { seq: number } { - const cursor = expectObject(value, "query.cursor"); - return { seq: expectFiniteNumber(cursor.seq, "query.cursor.seq") }; -} - -function expectFiniteNumber(value: unknown, name: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new ProtocolValidationError(`${name} must be a finite number`); - } - return value; -} - -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason; - return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"); -} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index fd69a2a04..bb7da1948 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,23 +1,13 @@ -import { - BACKGROUND_CONTEXT, - SessionInvalidLaneError, - SessionInvariantError, - SessionLaneExistsError, - type SessionMetadata, - SessionPendingAssistantMessageError, - SessionUnknownTargetError, - TODO_CONTEXT, - withAbortSignal, -} from "@earendil-works/pi-agent-core"; +import { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core"; import { type CancelEnvelope, type ClientHello, type ClientMessage, ClientMessageDecoder, DEFAULT_MAX_FRAME_LENGTH, + decodeServiceRpcCall, encodeServerMessage, isServerId, - isServiceRpcCall, isSupportedProtocolVersion, PROTOCOL_VERSION, type ProtocolError, @@ -25,6 +15,7 @@ import { ProtocolValidationError, type RequestEnvelope, type ResponseEnvelope, + type RpcTarget, type ServerHello, type ServerHelloError, type ServerMessage, @@ -36,9 +27,8 @@ import { isTerminalConnection, } from "./connection.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 { DEFAULT_REMOTE_MUTATION_LEASE_MS, RemoteSessionManager } from "./remote-session-manager.ts"; +import { SessionRouter } from "./session-router.ts"; import type { PiServerHost, PiServerOptions } from "./types.ts"; const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000; @@ -47,7 +37,7 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647; export class PiServer { readonly serverId: string; - /** Resolves after shutdown, or rejects when listener or hosted-Harness cleanup fails. */ + /** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */ readonly closed: Promise; private readonly listeners: readonly PiServerListener[]; @@ -56,8 +46,7 @@ export class PiServer { private readonly onConnectionCountChanged: ((count: number) => void) | undefined; private readonly onError: ((error: Error) => void) | undefined; private readonly connections = new Set(); - private readonly sessions: HostedHarnessManager; - private readonly remoteSessions: RemoteSessionManager; + private readonly sessions: SessionRouter; private closing = false; private closePromise?: Promise; private closedSettled = false; @@ -74,15 +63,9 @@ export class PiServer { this.handshakeTimeoutMs = resolved.handshakeTimeoutMs; this.onConnectionCountChanged = options.onConnectionCountChanged; this.onError = options.onError; - this.remoteSessions = new RemoteSessionManager({ - host, - mutationLeaseMs: resolved.remoteMutationLeaseMs, - isSessionUnavailable: (sessionId) => this.sessions.hasSession(sessionId), - }); - this.sessions = new HostedHarnessManager({ + this.sessions = new SessionRouter({ host, isClosing: () => this.closing, - isSessionUnavailable: (sessionId) => this.remoteSessions.hasSession(sessionId), reportError: (error) => this.reportError(error), }); this.closed = new Promise((resolve, reject) => { @@ -280,8 +263,11 @@ export class PiServer { } private handleCancel(state: ConnectionState, envelope: CancelEnvelope): void { - if (envelope.serverId !== this.serverId) return; - state.activeRequests.get(envelope.id)?.abort(new DOMException("RPC request cancelled", "AbortError")); + if (envelope.target.serverId !== this.serverId) return; + const active = state.activeRequests.get(envelope.id); + if (active !== undefined && sameTarget(active.target, envelope.target)) { + active.controller.abort(new DOMException("RPC request cancelled", "AbortError")); + } } private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise { @@ -295,24 +281,27 @@ export class PiServer { return; } const controller = new AbortController(); - state.activeRequests.set(envelope.id, controller); + const active = { controller, target: envelope.target }; + state.activeRequests.set(envelope.id, active); const context = withAbortSignal(controller.signal, TODO_CONTEXT); try { - if (envelope.serverId !== this.serverId) throw new WrongServerError(); + if (envelope.target.serverId !== this.serverId) throw new WrongServerError(); let result: ProtocolRpcResult; - if (isServiceRpcCall(envelope.call)) { + const call = decodeServiceRpcCall(envelope.call); + if (call !== undefined) { result = await this.sessions.executeCall( - envelope.call, + call, + envelope.target, state, async (message, _context) => { await this.sendMessage(state, message); }, context, ); - } else if (envelope.call.method.startsWith("session.")) { - result = await this.remoteSessions.invoke(envelope.call.method, envelope.call.args, state, context); } else { - throw new ProtocolValidationError(`Unknown RPC method ${envelope.call.method}`); + throw new ProtocolValidationError( + `Unknown service member ${envelope.call.serviceId}.${envelope.call.member}`, + ); } await this.sendMessage(state, { type: "response", @@ -330,7 +319,7 @@ export class PiServer { : this.toProtocolError(error), } satisfies ResponseEnvelope); } finally { - if (state.activeRequests.get(envelope.id) === controller) state.activeRequests.delete(envelope.id); + if (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id); } } @@ -350,15 +339,12 @@ export class PiServer { connection.disconnected = true; connection.stage = "closed"; clearTimeout(connection.handshakeTimeout); - for (const controller of connection.activeRequests.values()) { + for (const { controller } of connection.activeRequests.values()) { controller.abort(new Error("Client disconnected")); } connection.activeRequests.clear(); if (this.connections.delete(connection)) this.notifyConnectionCountChanged(); - void Promise.allSettled([ - this.sessions.disconnect(connection, TODO_CONTEXT), - this.remoteSessions.disconnect(connection, TODO_CONTEXT), - ]).then((results) => { + void Promise.allSettled([this.sessions.disconnect(connection, TODO_CONTEXT)]).then((results) => { for (const result of results) if (result.status === "rejected") this.reportError(result.reason); }); } @@ -408,10 +394,7 @@ export class PiServer { } await Promise.all(connections.map((connection) => this.closeConnection(connection.connection))); for (const connection of connections) this.disconnect(connection); - const cleanup = await Promise.allSettled([ - this.sessions.close(BACKGROUND_CONTEXT), - this.remoteSessions.close(BACKGROUND_CONTEXT), - ]); + const cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]); this.connections.clear(); const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); if (errors.length === 1) throw errors[0]; @@ -433,13 +416,6 @@ export class PiServer { if (error instanceof ProtocolValidationError) { return { code: "invalid_request", message: error.message }; } - if (error instanceof SessionInvalidLaneError) return { code: "session_invalid_lane", message: error.message }; - if (error instanceof SessionLaneExistsError) return { code: "session_lane_exists", message: error.message }; - if (error instanceof SessionUnknownTargetError) return { code: "session_unknown_target", message: error.message }; - if (error instanceof SessionPendingAssistantMessageError) { - return { code: "session_pending_message", message: error.message }; - } - if (error instanceof SessionInvariantError) return { code: "session_invariant", message: error.message }; this.reportError(error); return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE }; } @@ -468,10 +444,17 @@ export class PiServer { } } +function sameTarget(left: RpcTarget, right: RpcTarget): boolean { + if (left.serverId !== right.serverId) return false; + if (!("sessionId" in left) || !("sessionId" in right)) { + return !("sessionId" in left) && !("sessionId" in right); + } + return left.sessionId === right.sessionId && left.attachmentId === right.attachmentId; +} + function resolveOptions(options: PiServerOptions): { maxFrameLength: number; handshakeTimeoutMs: number; - remoteMutationLeaseMs: number; } { if (!Array.isArray(options.listeners)) throw new TypeError("PiServer listeners must be an array"); if (!isServerId(options.serverId)) { @@ -489,13 +472,5 @@ function resolveOptions(options: PiServerOptions): { ) { throw new TypeError(`PiServer handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`); } - const remoteMutationLeaseMs = options.remoteMutationLeaseMs ?? DEFAULT_REMOTE_MUTATION_LEASE_MS; - if ( - !Number.isSafeInteger(remoteMutationLeaseMs) || - remoteMutationLeaseMs <= 0 || - remoteMutationLeaseMs > MAX_TIMER_DELAY_MS - ) { - throw new TypeError(`PiServer remoteMutationLeaseMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`); - } - return { maxFrameLength, handshakeTimeoutMs, remoteMutationLeaseMs }; + return { maxFrameLength, handshakeTimeoutMs }; } diff --git a/packages/server/src/hosted-harness-manager.ts b/packages/server/src/session-router.ts similarity index 67% rename from packages/server/src/hosted-harness-manager.ts rename to packages/server/src/session-router.ts index 7e06d443a..836372f6c 100644 --- a/packages/server/src/hosted-harness-manager.ts +++ b/packages/server/src/session-router.ts @@ -4,52 +4,44 @@ import { createRpcDispatcher, type EventEnvelope, type PromptArguments, - type SessionMetadata as ProtocolSessionMetadata, ProtocolValidationError, + type RpcTarget, type RunResult, ServiceRpc, type ServiceRpcCall, type ServiceRpcResultUnion, + type SessionSummary, } from "@earendil-works/pi-protocol"; import { NotSupportedError, ServerDrainingError, SessionAmbiguousError, - SessionInUseError, SessionNotAttachedError, SessionNotFoundError, WatchInUseError, WatchNotFoundError, } from "./errors.ts"; -import type { HostedHarnessAttachment, HostedHarnessHandle, HostedHarnessWatch, PiServerHost } from "./types.ts"; +import type { PiServerHost, RoutedSessionAttachment, RoutedSessionHandle, RoutedSessionWatch } from "./types.ts"; -class HarnessCleanupError extends AggregateError {} +class SessionCleanupError 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 }), - }; +function toSessionSummary(serverId: string, metadata: SessionMetadata): SessionSummary { + return { serverId, sessionId: metadata.id, createdAt: metadata.createdAt }; } interface ActiveWatch { readonly id: string; - readonly handle: HostedHarnessWatch; + readonly handle: RoutedSessionWatch; started: boolean; } interface ClientAttachment { + readonly id: string; readonly client: object; readonly session: HostedSession; readonly prompts: Set>; - acquiring?: Promise; - lease?: HostedHarnessAttachment; + acquiring?: Promise; + lease?: RoutedSessionAttachment; releasing?: Promise; watch?: ActiveWatch; } @@ -57,24 +49,24 @@ interface ClientAttachment { interface RpcContext { readonly client: object; readonly context: Context; + readonly target: RpcTarget; publish(message: EventEnvelope, context: Context): Promise; } interface HostedSession { readonly id: string; - readonly harness: HostedHarnessHandle; - attachment?: ClientAttachment; + readonly handle: RoutedSessionHandle; + readonly attachments: Set; } -interface HostedHarnessManagerOptions { +interface SessionRouterOptions { host: PiServerHost; isClosing: () => boolean; - isSessionUnavailable?: (sessionId: string) => boolean; reportError: (error: unknown) => void; } -export class HostedHarnessManager { - private readonly options: HostedHarnessManagerOptions; +export class SessionRouter { + private readonly options: SessionRouterOptions; private readonly hostedSessions = new Map(); private readonly openingSessions = new Map>(); private readonly attachmentsByClient = new Map(); @@ -83,50 +75,57 @@ export class HostedHarnessManager; private readonly dispatchRpc: (call: ServiceRpcCall, context: RpcContext) => Promise; - constructor(options: HostedHarnessManagerOptions) { + constructor(options: SessionRouterOptions) { this.options = options; this.dispatchRpc = createRpcDispatcher( ServiceRpc, { - list: async ({ context }, ..._args: never[]) => - (await this.options.host.sessions.list(context)).map(toProtocolSessionMetadata), - create: async ({ client, context }, createOptions) => - this.runForClient(client, async () => { + list: async ({ context, target }, ..._args: never[]) => { + this.requireServerTarget(target); + return (await this.options.host.sessions.list(context)).map((metadata) => + toSessionSummary(target.serverId, metadata), + ); + }, + create: async ({ client, context, target }, createOptions) => { + this.requireServerTarget(target); + return this.runForClient(client, async () => { if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - return toProtocolSessionMetadata(await this.options.host.sessions.create(createOptions, context)); - }), - attach: async ({ client, context }, sessionId) => { + return toSessionSummary( + target.serverId, + await this.options.host.sessions.create(createOptions, context), + ); + }); + }, + attach: async ({ client, context, target }, sessionId) => { + this.requireServerTarget(target); if (this.options.isClosing()) throw new ServerDrainingError(); return this.runForClient(client, () => this.attachClient(client, sessionId, context)); }, - prompt: async ({ client, context }, sessionId, prompt) => { + prompt: async ({ client, context, target }, prompt) => { const admitted = await this.runForClient(client, () => - this.startPrompt(client, sessionId, prompt, context), + this.startPrompt(client, target, prompt, context), ); return admitted.result; }, - watch: ({ client, context }, sessionId) => - this.runForClient(client, () => this.createWatch(client, sessionId, context)), - startWatch: ({ client, publish, context }, sessionId, watchId) => - this.runForClient(client, () => this.startWatch(client, sessionId, watchId, publish, context)), - stopWatch: ({ client, context }, sessionId, watchId) => - this.runForClient(client, () => this.stopWatch(client, sessionId, watchId, context)), + watch: ({ client, context, target }, ..._args: never[]) => + this.runForClient(client, () => this.createWatch(client, target, context)), + startWatch: ({ client, publish, context, target }, watchId) => + this.runForClient(client, () => this.startWatch(client, target, watchId, publish, context)), + stopWatch: ({ client, context, target }, watchId) => + this.runForClient(client, () => this.stopWatch(client, target, watchId, context)), }, (message) => new ProtocolValidationError(message), ); } - hasSession(sessionId: string): boolean { - return this.hostedSessions.has(sessionId) || this.openingSessions.has(sessionId); - } - executeCall( call: ServiceRpcCall, + target: RpcTarget, client: object, publish: (message: EventEnvelope, context: Context) => Promise, context: Context, ): Promise { - return this.dispatchRpc(call, { client, publish, context }); + return this.dispatchRpc(call, { client, target, publish, context }); } async disconnect(client: object, context: Context): Promise { @@ -157,18 +156,18 @@ export class HostedHarnessManager - session.attachment ? [this.releaseAttachment(session.attachment, context)] : [], + [...session.attachments].map((attachment) => this.releaseAttachment(attachment, context)), ), ); for (const result of attachmentResults) { if (result.status === "rejected") closeErrors.push(result.reason); } const hosted = [...this.hostedSessions.values()]; - const closeResults = await Promise.allSettled(hosted.map(({ harness }) => harness.close(context))); + const closeResults = await Promise.allSettled(hosted.map(({ handle }) => handle.close(context))); for (let index = 0; index < closeResults.length; index++) { const result = closeResults[index]!; const session = hosted[index]!; @@ -181,7 +180,7 @@ export class HostedHarnessManager 0) throw new AggregateError(closeErrors, "Failed to close hosted Harnesses"); + if (closeErrors.length > 0) throw new AggregateError(closeErrors, "Failed to close routed Sessions"); } private runForClient(client: object, operation: () => Promise): Promise { @@ -198,36 +197,36 @@ export class HostedHarnessManager { + private async attachClient( + client: object, + sessionId: string, + context: Context, + ): Promise<{ sessionId: string; attachmentId: string }> { if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); const current = this.attachmentsByClient.get(client); - if (current?.session.id === sessionId) return { sessionId }; + if (current?.session.id === sessionId) return { sessionId, attachmentId: current.id }; if (current) await this.releaseAttachment(current, context); - if (this.options.isSessionUnavailable?.(sessionId)) throw new SessionInUseError(); const hosted = await this.acquire(sessionId, context); if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - const occupied = hosted.attachment; - if (occupied) { - if (occupied.client !== client) throw new SessionInUseError(); - return { sessionId }; - } - - const attachment: ClientAttachment = { client, session: hosted, prompts: new Set() }; - hosted.attachment = attachment; + const attachment: ClientAttachment = { + id: randomUUID(), + client, + session: hosted, + prompts: new Set(), + }; + hosted.attachments.add(attachment); try { - const acquiring = Promise.resolve( - hosted.harness.attachClient ? hosted.harness.attachClient(context) : { release: (_context: Context) => {} }, - ); + const acquiring = Promise.resolve(hosted.handle.attachClient(context)); attachment.acquiring = acquiring; attachment.lease = await acquiring; } catch (error) { - if (hosted.attachment === attachment) hosted.attachment = undefined; + hosted.attachments.delete(attachment); throw error; } if ( this.hostedSessions.get(hosted.id) !== hosted || - hosted.attachment !== attachment || + !hosted.attachments.has(attachment) || this.disconnectedClients.has(client) || this.options.isClosing() ) { @@ -235,19 +234,19 @@ export class HostedHarnessManager }> { - if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - const attachment = this.attachmentsByClient.get(client); - if (!attachment || attachment.session.id !== sessionId) throw new SessionNotAttachedError(); - const result = attachment.session.harness.prompt(prompt, context); + const attachment = this.requireAttachment(client, target); + const lease = attachment.lease; + if (lease === undefined) throw new SessionNotAttachedError(); + const result = lease.prompt(prompt, context); attachment.prompts.add(result); const remove = (): void => { attachment.prompts.delete(result); @@ -258,14 +257,14 @@ export class HostedHarnessManager { - const attachment = this.requireAttachment(client, sessionId); + ): Promise<{ watchId: string; snapshot: RoutedSessionWatch["snapshot"] }> { + const attachment = this.requireAttachment(client, target); if (attachment.watch !== undefined) throw new WatchInUseError(); - const create = attachment.session.harness.watch; - if (create === undefined) throw new NotSupportedError("Hosted Harness does not support lane watches"); - const handle = await create.call(attachment.session.harness, context); + const create = attachment.lease?.watch; + if (create === undefined) throw new NotSupportedError("Routed Session does not support lane watches"); + const handle = await create.call(attachment.lease, context); const watch = { id: randomUUID(), handle, started: false } satisfies ActiveWatch; attachment.watch = watch; return { watchId: watch.id, snapshot: handle.snapshot }; @@ -273,12 +272,12 @@ export class HostedHarnessManager Promise, context: Context, ): Promise<{ watchId: string }> { - const attachment = this.requireAttachment(client, sessionId); + const attachment = this.requireAttachment(client, target); const watch = attachment.watch; if (watch?.id !== watchId) throw new WatchNotFoundError(); if (!watch.started) { @@ -303,11 +302,11 @@ export class HostedHarnessManager { - const attachment = this.requireAttachment(client, sessionId); + const attachment = this.requireAttachment(client, target); const watch = attachment.watch; if (watch?.id !== watchId) throw new WatchNotFoundError(); delete attachment.watch; @@ -315,13 +314,20 @@ export class HostedHarnessManager { attachment.releasing ??= (async () => { const errors: unknown[] = []; @@ -350,7 +356,7 @@ export class HostedHarnessManager 1) throw new SessionAmbiguousError(); const metadata = matches[0]!; - const harness = await this.options.host.createHarness(metadata, context); + const handle = await this.options.host.openSession(metadata, context); if (this.options.isClosing()) { try { - await harness.close(context); + await handle.close(context); } catch (error) { this.options.reportError(error); - throw new HarnessCleanupError( + throw new SessionCleanupError( [new ServerDrainingError(), error], - "Failed to close Harness acquired while draining", + "Failed to close routed Session acquired while draining", ); } throw new ServerDrainingError(); } - const hosted: HostedSession = { id: metadata.id, harness }; + const hosted: HostedSession = { id: metadata.id, handle, attachments: new Set() }; this.hostedSessions.set(hosted.id, hosted); - if (harness.terminated) { - void harness.terminated.then( + if (handle.terminated) { + void handle.terminated.then( (error) => this.invalidate(hosted, error), (error: unknown) => this.invalidate(hosted, error instanceof Error ? error : new Error(String(error))), ); @@ -404,8 +410,7 @@ export class HostedHarnessManager this.options.reportError(releaseError), ); diff --git a/packages/server/src/testing/client.ts b/packages/server/src/testing/client.ts index 3d57835fe..6e0a8b727 100644 --- a/packages/server/src/testing/client.ts +++ b/packages/server/src/testing/client.ts @@ -3,14 +3,21 @@ import { createConnection, type Socket } from "node:net"; import { type ClientMessage, encodeClientMessage, + isJsonValue, PROTOCOL_VERSION, type ProtocolRpcCall, type ResponseEnvelope, + type RpcTarget, type ServerMessage, ServerMessageDecoder, } from "@earendil-works/pi-protocol"; import { Deferred } from "./host.ts"; +interface TestRpcCall { + readonly method: string; + readonly args: unknown; +} + interface MessageWaiter { predicate: (message: ServerMessage) => boolean; resolve: (message: ServerMessage) => void; @@ -30,6 +37,7 @@ export class ProtocolTestClient { private readonly waiters = new Set(); private readonly closedDeferred = new Deferred(); private requestSequence = 0; + private attachment: { sessionId: string; attachmentId: string } | undefined; private closedValue = false; constructor(channel: WireChannel) { @@ -48,20 +56,58 @@ export class ProtocolTestClient { async request( serverId: string, - call: ProtocolRpcCall, + call: TestRpcCall, id = `request-${++this.requestSequence}`, ): Promise { const response = this.next( (message): message is ResponseEnvelope => message.type === "response" && message.id === id, ); - await this.sendMessage({ type: "request", id, serverId, call }); - return (await response) as ResponseEnvelope; + const routed = this.routeLegacyCall(serverId, call); + await this.sendMessage({ type: "request", id, ...routed }); + const received = (await response) as ResponseEnvelope; + if ( + call.method === "attach" && + received.ok && + typeof received.result === "object" && + received.result !== null && + "sessionId" in received.result && + "attachmentId" in received.result && + typeof received.result.sessionId === "string" && + typeof received.result.attachmentId === "string" + ) { + this.attachment = { + sessionId: received.result.sessionId, + attachmentId: received.result.attachmentId, + }; + } + return received; } sendMessage(message: ClientMessage): Promise { return this.channel.send(encodeClientMessage(message)); } + private routeLegacyCall(serverId: string, call: TestRpcCall): { target: RpcTarget; call: ProtocolRpcCall } { + if (!Array.isArray(call.args) || !call.args.every(isJsonValue)) { + throw new Error(`Test call ${call.method} requires JSON array arguments`); + } + const sessionOperation = ["prompt", "watch", "startWatch", "stopWatch"].includes(call.method); + let target: RpcTarget = { serverId }; + let args = call.args; + if (sessionOperation) { + if (typeof call.args[0] !== "string") throw new Error(`Test call ${call.method} requires a Session ID`); + const sessionId = call.args[0]; + const attachment = this.attachment; + target = + attachment === undefined || attachment.sessionId !== sessionId + ? { serverId, sessionId, attachmentId: "missing-attachment" } + : { serverId, ...attachment }; + args = call.args.slice(1); + } + const address = serviceAddress(call.method); + return { target, call: { ...address, args } }; + } + sendBytes(chunk: Uint8Array): Promise { return this.channel.send(chunk); } @@ -117,6 +163,24 @@ export class ProtocolTestClient { } } +function serviceAddress(method: string): { serviceId: string; member: string } { + switch (method) { + case "list": + return { serviceId: "session-directory", member: "list" }; + case "create": + case "attach": + return { serviceId: "session-management", member: method }; + case "prompt": + return { serviceId: "chat", member: "prompt" }; + case "watch": + case "startWatch": + case "stopWatch": + return { serviceId: "transcript", member: method }; + default: + return { serviceId: "unknown", member: method }; + } +} + export async function connectUnixTestClient(path: string): Promise { const socket = createConnection(path); await once(socket, "connect"); diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts index 2a7ac8807..69da94386 100644 --- a/packages/server/src/testing/host.ts +++ b/packages/server/src/testing/host.ts @@ -1,7 +1,7 @@ import type { Context, Session, SessionMetadata } from "@earendil-works/pi-agent-core"; import { BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core"; import type { LaneEvent, LaneSnapshot, PromptArguments, RunResult } from "@earendil-works/pi-protocol"; -import type { HostedHarnessHandle, HostedHarnessWatch, PiServerHost } from "../types.ts"; +import type { PiServerHost, RoutedSessionHandle, RoutedSessionWatch } from "../types.ts"; export class Deferred { readonly promise: Promise; @@ -33,7 +33,7 @@ const emptyLaneSnapshot: LaneSnapshot = { faulted: false, }; -class TestHarnessWatch implements HostedHarnessWatch { +class TestHarnessWatch implements RoutedSessionWatch { readonly snapshot: LaneSnapshot; private readonly buffered: Array<{ event: LaneEvent; context: Context }> = []; private listener: ((event: LaneEvent, context: Context) => void | Promise) | undefined; @@ -97,10 +97,16 @@ export class TestHarness { this.session = session; } - attachClient(_context: Context): { release(context: Context): void } { + attachClient(_context: Context): { + prompt: TestHarness["prompt"]; + watch: TestHarness["watch"]; + release(context: Context): void; + } { this.attachedClients += 1; let released = false; return { + prompt: (prompt, context) => this.prompt(prompt, context), + watch: (context) => this.watch(context), release: (_context) => { if (released) return; this.attachmentReleaseCount += 1; @@ -111,7 +117,7 @@ export class TestHarness { }; } - async watch(_context: Context): Promise { + async watch(_context: Context): Promise { const watch = new TestHarnessWatch(this.watchSnapshot); this.watches.add(watch); return { @@ -193,8 +199,8 @@ interface ListDelay { export class TestServerHost implements PiServerHost { readonly repo = new MemorySessionRepo({ now: () => 1 }); readonly harnesses = new Map(); - createHarnessCount = 0; - nextCreateHarnessError?: Error; + openSessionCount = 0; + nextOpenSessionError?: Error; nextHarnessCloseError?: Error; readonly sessions: PiServerHost["sessions"] = { list: async (context) => { @@ -214,24 +220,23 @@ export class TestServerHost implements PiServerHost { await session.close(context); } }, - open: (metadata, context) => this.repo.open(metadata, context), }; private nextListDelay?: ListDelay; - private nextCreateHarnessGate?: OpenGate; + private nextOpenSessionGate?: OpenGate; - async createHarness(metadata: SessionMetadata, context: Context): Promise { - this.createHarnessCount += 1; - const gate = this.nextCreateHarnessGate; + async openSession(metadata: SessionMetadata, context: Context): Promise { + this.openSessionCount += 1; + const gate = this.nextOpenSessionGate; if (gate) { - this.nextCreateHarnessGate = undefined; + this.nextOpenSessionGate = undefined; gate.entered.resolve(undefined); await gate.release.promise; } const session = await this.repo.open(metadata, context); try { - if (this.nextCreateHarnessError) { - const error = this.nextCreateHarnessError; - this.nextCreateHarnessError = undefined; + if (this.nextOpenSessionError) { + const error = this.nextOpenSessionError; + this.nextOpenSessionError = undefined; throw error; } const harness = new TestHarness(session); @@ -262,9 +267,9 @@ export class TestServerHost implements PiServerHost { return delay; } - gateNextCreateHarness(): OpenGate { + gateNextOpenSession(): OpenGate { const gate = { entered: new Deferred(), release: new Deferred() }; - this.nextCreateHarnessGate = gate; + this.nextOpenSessionGate = gate; return gate; } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 62a6da7a7..a3d0ea178 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,4 +1,4 @@ -import type { Context, Session, SessionMetadata } from "@earendil-works/pi-agent-core"; +import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core"; import type { LaneEvent, LaneSnapshot, @@ -16,44 +16,39 @@ export interface PiServerOptions { handshakeTimeoutMs?: number; onConnectionCountChanged?: (count: number) => void; onError?: (error: Error) => void; - /** Maximum time an acquired remote Session mutation may hold its lane. */ - remoteMutationLeaseMs?: number; } export type MaybePromise = T | Promise; -/** One client connection's exclusive attachment to a hosted Session. */ -export interface HostedHarnessAttachment { +/** One presentation connection's live capability for a hosted Session. */ +export interface RoutedSessionAttachment { + /** Execute one serializable prompt through this attachment. */ + prompt(prompt: PromptArguments, context: Context): Promise; + /** Observe the attached main lane when supported by this host. */ + watch?(context: Context): Promise; release(context: Context): MaybePromise; } -/** Snapshot-first observation handle supplied by a hosted Harness adapter. */ -export interface HostedHarnessWatch { +/** Snapshot-first observation handle supplied by a routed Session attachment. */ +export interface RoutedSessionWatch { readonly snapshot: LaneSnapshot; start(listener: (event: LaneEvent, context: Context) => MaybePromise, context: Context): MaybePromise; unsubscribe(context: Context): MaybePromise; } -/** A process-safe handle for the hosted Harness operations exposed by this server. */ -export interface HostedHarnessHandle { - /** Acquire the Session for one client connection. Sessions permit only one attachment at a time. */ - attachClient?(context: Context): MaybePromise; - /** Execute one serializable prompt against the hosted Harness. */ - prompt(prompt: PromptArguments, context: Context): Promise; - /** Observe the attached main lane when supported by this host. */ - watch?(context: Context): Promise; +/** A process-safe handle that acquires presentation-scoped Session capabilities. */ +export interface RoutedSessionHandle { + attachClient(context: Context): MaybePromise; /** Resolves with an error for unexpected termination, or undefined after an expected close. */ readonly terminated?: Promise; close(context: Context): Promise; } -/** Host capabilities used directly by Session RPC operations. */ +/** Application capabilities used by server-wide management and Session routing. */ export interface PiServerHost { readonly sessions: { list(context: Context): Promise; create(options: SessionCreateOptions, context: Context): Promise; - /** Open one exclusive Session capability for a remote harness client. */ - open?(metadata: TMetadata, context: Context): Promise>; }; - createHarness(metadata: TMetadata, context: Context): Promise; + openSession(metadata: TMetadata, context: Context): Promise; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index b7f12241e..5017fe36d 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -65,7 +65,7 @@ describe("Session protocol", () => { expect(host.harnesses.size).toBe(0); }); - test("list returns SessionRepo metadata without opening a session", async () => { + test("list returns presentation-safe summaries without opening a Session", async () => { const host = new TestServerHost(); const metadata = await host.seed("session-1", "parent-1"); const client = connect(createServer(host)); @@ -77,7 +77,13 @@ describe("Session protocol", () => { type: "response", id: "request-1", ok: true, - result: [metadata], + result: [ + { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: metadata.id, + createdAt: metadata.createdAt, + }, + ], }); expect(host.harnesses.size).toBe(0); }); @@ -90,105 +96,22 @@ describe("Session protocol", () => { await expect( client.request("00000000-0000-4000-8000-000000000001", { method: "create", - args: [{ id: "session-1", cwd: "/workspace" }], + args: [{ id: "session-1" }], }), ).resolves.toMatchObject({ ok: true, - result: { id: "session-1", createdAt: 1, storageVersion: 1 }, + result: { + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + createdAt: 1, + }, }); await expect( client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }), - ).resolves.toMatchObject({ result: [{ id: "session-1" }] }); + ).resolves.toMatchObject({ result: [{ sessionId: "session-1" }] }); expect(host.harnesses.size).toBe(0); }); - test("routes untyped remote Session calls through a connection-owned handle", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - - const opened = await client.request("00000000-0000-4000-8000-000000000001", { - method: "session.open", - args: { sessionId: "session-1" }, - }); - expect(opened).toMatchObject({ ok: true, result: { metadata: { id: "session-1" } } }); - if (!opened.ok || typeof opened.result !== "object" || opened.result === null || !("handle" in opened.result)) { - throw new Error("Missing remote Session handle"); - } - const handle = opened.result.handle; - expect(typeof handle).toBe("string"); - await expect( - client.request("00000000-0000-4000-8000-000000000001", { - method: "session.tree.getLeafId", - args: { handle, lane: "main" }, - }), - ).resolves.toMatchObject({ ok: true, result: null }); - await expect( - client.request("00000000-0000-4000-8000-000000000001", { - method: "session.close", - args: { handle }, - }), - ).resolves.toMatchObject({ ok: true, result: null }); - }); - - test("cancels a queued remote mutation without cancelling its lane owner", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - const serverId = "00000000-0000-4000-8000-000000000001"; - await client.hello(); - const opened = await client.request(serverId, { - method: "session.open", - args: { sessionId: "session-1" }, - }); - if (!opened.ok || typeof opened.result !== "object" || opened.result === null || !("handle" in opened.result)) { - throw new Error("Missing remote Session handle"); - } - const handle = opened.result.handle; - if (typeof handle !== "string") throw new Error("Invalid remote Session handle"); - const first = await client.request(serverId, { - method: "session.mutation.begin", - args: { handle, lane: "main", mutationLane: "main" }, - }); - if (!first.ok || typeof first.result !== "object" || first.result === null || !("mutationId" in first.result)) { - throw new Error("Missing remote mutation ID"); - } - const firstMutationId = first.result.mutationId; - if (typeof firstMutationId !== "string") throw new Error("Invalid remote mutation ID"); - - const queued = client.request( - serverId, - { - method: "session.mutation.begin", - args: { handle, lane: "main", mutationLane: "main" }, - }, - "queued-mutation", - ); - await Promise.resolve(); - await client.sendMessage({ type: "cancel", id: "queued-mutation", serverId }); - await expect(queued).resolves.toMatchObject({ ok: false, error: { code: "cancelled" } }); - - await expect( - client.request(serverId, { - method: "session.mutation.finish", - args: { handle, mutationId: firstMutationId }, - }), - ).resolves.toMatchObject({ ok: true, result: null }); - const next = await client.request(serverId, { - method: "session.mutation.begin", - args: { handle, lane: "main", mutationLane: "main" }, - }); - expect(next).toMatchObject({ ok: true, result: { mutationId: expect.any(String) } }); - if (!next.ok || typeof next.result !== "object" || next.result === null || !("mutationId" in next.result)) { - throw new Error("Missing replacement mutation ID"); - } - await client.request(serverId, { - method: "session.mutation.finish", - args: { handle, mutationId: next.result.mutationId }, - }); - }); - test("attach passes concrete repository metadata to the Harness host", async () => { type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number }; const metadata: BackendMetadata = { @@ -202,12 +125,15 @@ describe("Session protocol", () => { let received: BackendMetadata | undefined; const host: PiServerHost = { sessions: { list: async () => [metadata], create: async () => metadata }, - createHarness: async (candidate) => { + openSession: async (candidate) => { received = candidate; return { - prompt: async () => ({ - ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + attachClient: async () => ({ + prompt: async () => ({ + ok: true, + value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" }, + }), + release: () => {}, }), close: async () => {}, }; @@ -229,10 +155,9 @@ describe("Session protocol", () => { ok: true, result: [ { - id: "session-1", + serverId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", createdAt: 1, - storageVersion: 1, - cwd: "/workspace", }, ], }); @@ -321,39 +246,7 @@ describe("Session protocol", () => { expect(client.messages.slice(stoppedAt).some((message) => message.type === "event")).toBe(false); }); - test("keeps hosted Harness and remote Session ownership mutually exclusive", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const remoteClient = connect(server); - const hostedClient = connect(server); - const serverId = "00000000-0000-4000-8000-000000000001"; - await Promise.all([remoteClient.hello(), hostedClient.hello()]); - - const opened = await remoteClient.request(serverId, { - method: "session.open", - args: { sessionId: "session-1" }, - }); - await expect(hostedClient.request(serverId, { method: "attach", args: ["session-1"] })).resolves.toMatchObject({ - ok: false, - error: { code: "session_in_use" }, - }); - if (!opened.ok || typeof opened.result !== "object" || opened.result === null || !("handle" in opened.result)) { - throw new Error("Missing remote Session handle"); - } - await remoteClient.request(serverId, { - method: "session.close", - args: { handle: opened.result.handle }, - }); - await expect(hostedClient.request(serverId, { method: "attach", args: ["session-1"] })).resolves.toMatchObject({ - ok: true, - }); - await expect( - remoteClient.request(serverId, { method: "session.open", args: { sessionId: "session-1" } }), - ).resolves.toMatchObject({ ok: false, error: { code: "session_in_use" } }); - }); - - test("permits only one client attachment per Session", async () => { + test("permits multiple client attachments per Session", async () => { const host = new TestServerHost(); await host.seed("session-1"); const server = createServer(host); @@ -379,12 +272,12 @@ describe("Session protocol", () => { method: "attach", args: ["session-1"], }), - ).resolves.toMatchObject({ ok: false, error: { code: "session_in_use" } }); + ).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } }); expect(host.harnesses.get("session-1")).toHaveLength(1); - expect(host.latestHarness("session-1").attachedClients).toBe(1); + expect(host.latestHarness("session-1").attachedClients).toBe(2); await first.close(); - await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(0); + await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(1); await expect( second.request("00000000-0000-4000-8000-000000000001", { method: "attach", @@ -465,6 +358,39 @@ describe("Session protocol", () => { expect(host.latestHarness("session-1").promptCalls).toEqual([["Hello"]]); }); + test("rejects a stale attachment route after switching Sessions", async () => { + const host = new TestServerHost(); + await Promise.all([host.seed("session-1"), host.seed("session-2")]); + const client = connect(createServer(host)); + const serverId = "00000000-0000-4000-8000-000000000001"; + await client.hello(); + const first = await client.request(serverId, { method: "attach", args: ["session-1"] }); + if ( + !first.ok || + typeof first.result !== "object" || + first.result === null || + !("attachmentId" in first.result) || + typeof first.result.attachmentId !== "string" + ) { + throw new Error("Missing first attachment ID"); + } + await client.request(serverId, { method: "attach", args: ["session-2"] }); + + const response = client.next((message) => message.type === "response" && message.id === "stale-request"); + await client.sendMessage({ + type: "request", + id: "stale-request", + target: { serverId, sessionId: "session-1", attachmentId: first.result.attachmentId }, + call: { serviceId: "chat", member: "prompt", args: [["stale"]] }, + }); + await expect(response).resolves.toMatchObject({ + type: "response", + ok: false, + error: { code: "session_not_attached" }, + }); + expect(host.latestHarness("session-1").promptCalls).toEqual([]); + }); + test("preserves structural Harness failures and bounds adapter defects", async () => { const host = new TestServerHost(); await host.seed("session-1"); @@ -593,7 +519,7 @@ describe("Session protocol", () => { throw new Error("must not create a Session in this test"); }, }, - createHarness: async () => { + openSession: async () => { throw new Error("must not create a Harness for an ambiguous session"); }, }; @@ -647,7 +573,7 @@ describe("Session protocol", () => { }); }); -describe("hosted Harness acquisition failures", () => { +describe("routed Session acquisition failures", () => { test("releases a lease acquired concurrently with Harness termination", async () => { const metadata: SessionMetadata = { id: "session-1", createdAt: 1, storageVersion: 1 }; const acquiring = new Deferred(); @@ -656,21 +582,21 @@ describe("hosted Harness acquisition failures", () => { let releaseCount = 0; const host: PiServerHost = { sessions: { list: async () => [metadata], create: async () => metadata }, - createHarness: async () => ({ + openSession: async () => ({ terminated: terminated.promise, attachClient: async () => { acquiring.resolve(undefined); await continueAcquiring.promise; return { + prompt: async () => ({ + ok: true, + value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" }, + }), release: () => { releaseCount += 1; }, }; }, - prompt: async () => ({ - ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, - }), close: async () => {}, }), }; @@ -696,12 +622,12 @@ describe("hosted Harness acquisition failures", () => { 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.nextCreateHarnessError = new Error("Harness creation failed"); + host.nextOpenSessionError = 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.gateNextCreateHarness(); + const gate = host.gateNextOpenSession(); const firstAttach = first.request("00000000-0000-4000-8000-000000000001", { method: "attach", @@ -718,12 +644,12 @@ describe("hosted Harness acquisition failures", () => { { ok: false, error: { code: "internal_error" } }, { ok: false, error: { code: "internal_error" } }, ]); - expect(host.createHarnessCount).toBe(1); + expect(host.openSessionCount).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.createHarnessCount).toBe(2); + expect(host.openSessionCount).toBe(2); expect(host.harnesses.get("session-1")).toHaveLength(1); }); @@ -733,7 +659,7 @@ describe("hosted Harness acquisition failures", () => { const server = createServer(host); const client = connect(server); await client.hello(); - const gate = host.gateNextCreateHarness(); + const gate = host.gateNextOpenSession(); const attach = client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"], @@ -755,7 +681,7 @@ describe("hosted Harness acquisition failures", () => { const server = createServer(host); const client = connect(server); await client.hello(); - const gate = host.gateNextCreateHarness(); + const gate = host.gateNextOpenSession(); const attach = client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"], @@ -765,8 +691,8 @@ describe("hosted Harness acquisition failures", () => { 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(closing).rejects.toThrow(/Failed to close routed Sessions/); + await expect(server.closed).rejects.toThrow(/Failed to close routed Sessions/); await expect(attach).rejects.toThrow(/closed/i); expect(host.latestHarness("session-1").closeCount).toBe(1); diff --git a/packages/server/test/protocol.test.ts b/packages/server/test/protocol.test.ts index 897f3efb1..0a11f4c5b 100644 --- a/packages/server/test/protocol.test.ts +++ b/packages/server/test/protocol.test.ts @@ -1,4 +1,4 @@ -import { encodeCbor, encodeClientMessage, encodeFrame } from "@earendil-works/pi-protocol"; +import { encodeCbor, encodeClientMessage, encodeFrame, PROTOCOL_VERSION } from "@earendil-works/pi-protocol"; import { afterEach, expect, test } from "vitest"; import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; import { PiServer } from "../src/server.ts"; @@ -53,8 +53,8 @@ test("requires hello as the first message", async () => { await client.sendMessage({ type: "request", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list", args: [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory", member: "list", args: [] }, }); await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ type: "hello_error", @@ -65,15 +65,18 @@ test("requires hello as the first message", async () => { test("rejects unsupported protocol versions", async () => { const client = connect(); - await expect(client.hello(2)).resolves.toMatchObject({ type: "hello_error", error: { code: "version" } }); + await expect(client.hello(PROTOCOL_VERSION + 1)).resolves.toMatchObject({ + type: "hello_error", + error: { code: "version" }, + }); await client.waitForClose(); }); test("accepts fragmented hello and request frames", async () => { const client = connect(); - const hello = encodeClientMessage({ type: "hello", version: 1 }); + const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }); const helloResponse = client.next((message) => message.type === "hello"); - await client.sendFragmentedMessage({ type: "hello", version: 1 }, Math.floor(hello.byteLength / 2)); + await client.sendFragmentedMessage({ type: "hello", version: PROTOCOL_VERSION }, Math.floor(hello.byteLength / 2)); await expect(helloResponse).resolves.toMatchObject({ type: "hello", serverId: "00000000-0000-4000-8000-000000000001", @@ -83,8 +86,8 @@ test("accepts fragmented hello and request frames", async () => { const request = { type: "request" as const, id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list" as const, args: [] as [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory" as const, member: "list" as const, args: [] as [] }, }; const frame = encodeClientMessage(request); await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2)); @@ -108,7 +111,7 @@ test.each([ test("rejects a second hello after completing the handshake", async () => { const client = connect(); await client.hello(); - await client.sendMessage({ type: "hello", version: 1 }); + await client.sendMessage({ type: "hello", version: PROTOCOL_VERSION }); await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ type: "hello_error", error: { code: "invalid_request", message: expect.stringMatching(/first message/) }, @@ -118,12 +121,12 @@ test("rejects a second hello after completing the handshake", async () => { test("processes a hello and request coalesced in one byte chunk", async () => { const client = connect(); - const hello = encodeClientMessage({ type: "hello", version: 1 }); + const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }); const request = encodeClientMessage({ type: "request", id: "request-1", - serverId: "00000000-0000-4000-8000-000000000001", - call: { method: "list", args: [] }, + target: { serverId: "00000000-0000-4000-8000-000000000001" }, + call: { serviceId: "session-directory", member: "list", args: [] }, }); const wire = new Uint8Array(hello.byteLength + request.byteLength); wire.set(hello); diff --git a/packages/server/test/remote-session.test.ts b/packages/server/test/remote-session.test.ts deleted file mode 100644 index 1bf7fc370..000000000 --- a/packages/server/test/remote-session.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - AgentHarness, - BACKGROUND_CONTEXT, - insertEntry, - MemorySessionRepo, - RemoteSession, - type StorageBranchScan, - setValue, - value, -} from "@earendil-works/pi-agent-core"; -import { createModels, type Model } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, test } from "vitest"; -import { RemoteSessionManager } from "../src/remote-session-manager.ts"; -import type { PiServerHost } from "../src/types.ts"; - -const repos = new Set(); - -async function createRemoteSession( - id: string, - mutationLeaseMs = 1_000, -): Promise<{ - remote: RemoteSession; - manager: RemoteSessionManager; - repo: MemorySessionRepo; - client: object; -}> { - const repo = new MemorySessionRepo(); - repos.add(repo); - const created = await repo.create({ id }, BACKGROUND_CONTEXT); - await created.close(BACKGROUND_CONTEXT); - const host: PiServerHost = { - sessions: { - list: (context) => repo.list(undefined, context), - create: async (options, context) => { - const session = await repo.create(options, context); - try { - return session.metadata; - } finally { - await session.close(context); - } - }, - open: (metadata, context) => repo.open(metadata, context), - }, - createHarness: async () => { - throw new Error("Harness hosting is not used by remote Session tests"); - }, - }; - const manager = new RemoteSessionManager({ host, mutationLeaseMs }); - const client = {}; - const remote = await RemoteSession.open( - { - invoke: (method, args, context) => manager.invoke(method, args, client, context), - }, - id, - BACKGROUND_CONTEXT, - ); - return { remote, manager, repo, client }; -} - -afterEach(async () => { - await Promise.all([...repos].map((repo) => repo.close(BACKGROUND_CONTEXT))); - repos.clear(); -}); - -describe("RemoteSession", () => { - test("preserves callback-scoped mutation and tree behavior over untyped RPC", async () => { - const { remote } = await createRemoteSession("remote-session"); - const applicationValue = value("app.test", "value"); - let capturedMutator: Parameters[1]>[0] | undefined; - - await expect( - remote.mutate( - "main", - async (mutator, context) => { - capturedMutator = mutator; - await expect(mutator.getValue(applicationValue, context)).resolves.toBeUndefined(); - const committed = mutator.commit([setValue(applicationValue, "committed")], context); - await expect(mutator.commit([], context)).rejects.toThrow("already attempted"); - await committed; - return "local-result"; - }, - BACKGROUND_CONTEXT, - ), - ).resolves.toBe("local-result"); - await expect(remote.getValue(applicationValue, BACKGROUND_CONTEXT)).resolves.toMatchObject({ - value: "committed", - }); - await expect(capturedMutator?.getValue(applicationValue, BACKGROUND_CONTEXT)).rejects.toThrow( - "outside its mutation callback", - ); - await expect(capturedMutator?.scanBranch({ start: "entry" }, BACKGROUND_CONTEXT)).rejects.toThrow( - "outside its mutation callback", - ); - - const rootId = "00000000-0000-7000-8000-000000000001"; - const childId = "00000000-0000-7000-8000-000000000002"; - await remote.mutate( - "main", - (mutator, context) => - mutator.commit( - [ - insertEntry({ id: rootId, parentId: null, type: "custom", customType: "root" }), - insertEntry({ id: childId, parentId: rootId, type: "custom", customType: "child" }), - ], - context, - ), - BACKGROUND_CONTEXT, - ); - await expect( - remote.scanBranch({ start: childId, order: "oldestFirst" }, BACKGROUND_CONTEXT), - ).resolves.toMatchObject([{ id: rootId }, { id: childId }]); - const child = (await remote.getEntries([childId], BACKGROUND_CONTEXT)).get(childId); - if (child === undefined) throw new Error("Expected child entry"); - await expect( - remote.scanBranch( - { - start: childId, - stopAtId: rootId, - type: "custom", - customType: "root", - order: "newestFirst", - limit: 2, - cursor: { seq: child.seq }, - }, - BACKGROUND_CONTEXT, - ), - ).resolves.toMatchObject([{ id: rootId }]); - await expect( - remote.scanBranch({ start: childId, stopAtType: "custom" }, BACKGROUND_CONTEXT), - ).resolves.toMatchObject([{ id: childId }]); - await remote.mutate( - "main", - async (mutator, context) => { - await expect(mutator.scanBranch({ start: childId, limit: 1 }, context)).resolves.toMatchObject([ - { id: childId }, - ]); - }, - BACKGROUND_CONTEXT, - ); - - await remote.setValue(applicationValue, "tree-write", BACKGROUND_CONTEXT); - await expect(remote.getValue(applicationValue, BACKGROUND_CONTEXT)).resolves.toMatchObject({ - value: "tree-write", - }); - const review = await remote.createLane( - "review", - null, - { - model: { provider: "test", modelId: "model" }, - thinkingLevel: "off", - activeToolNames: [], - }, - undefined, - BACKGROUND_CONTEXT, - ); - await expect(review.getLeafId(BACKGROUND_CONTEXT)).resolves.toBeNull(); - await remote.close(BACKGROUND_CONTEXT); - }); - - test("validates callback-scoped branch scan RPC arguments", async () => { - const { remote } = await createRemoteSession("remote-scan-validation"); - const invalidQueries: unknown[] = [ - null, - {}, - { start: "" }, - { start: "entry", type: "invalid" }, - { start: "entry", order: "sideways" }, - { start: "entry", limit: Number.NaN }, - { start: "entry", cursor: { seq: "one" } }, - ]; - for (const query of invalidQueries) { - await expect(remote.scanBranch(query as StorageBranchScan, BACKGROUND_CONTEXT)).rejects.toThrow(); - } - await remote.mutate( - "main", - async (mutator, context) => { - await expect( - mutator.scanBranch({ start: "entry", stopAtType: "invalid" } as unknown as StorageBranchScan, context), - ).rejects.toThrow("query.stopAtType"); - }, - BACKGROUND_CONTEXT, - ); - await remote.close(BACKGROUND_CONTEXT); - }); - - test("expires a wedged mutation and releases its lane", async () => { - const { remote } = await createRemoteSession("remote-expiry", 10); - const applicationValue = value("app.test", "expiry"); - - await expect( - remote.mutate( - "main", - async (mutator, context) => { - await new Promise((resolve) => setTimeout(resolve, 25)); - await mutator.getValue(applicationValue, context); - }, - BACKGROUND_CONTEXT, - ), - ).rejects.toThrow(/mutation/i); - await remote.setValue(applicationValue, "after-expiry", BACKGROUND_CONTEXT); - await expect(remote.getValue(applicationValue, BACKGROUND_CONTEXT)).resolves.toMatchObject({ - value: "after-expiry", - }); - await remote.close(BACKGROUND_CONTEXT); - }); - - test("releases active mutations and the Session on disconnect", async () => { - const { remote, manager, repo, client } = await createRemoteSession("remote-disconnect"); - let markEntered!: () => void; - const entered = new Promise((resolve) => { - markEntered = resolve; - }); - let releaseCallback!: () => void; - const callbackGate = new Promise((resolve) => { - releaseCallback = resolve; - }); - const mutation = remote.mutate( - "main", - async () => { - markEntered(); - await callbackGate; - }, - BACKGROUND_CONTEXT, - ); - const rejectedMutation = expect(mutation).rejects.toThrow(/Session|mutation/i); - await entered; - await manager.disconnect(client, BACKGROUND_CONTEXT); - releaseCallback(); - await rejectedMutation; - - const metadata = (await repo.list(undefined, BACKGROUND_CONTEXT))[0]!; - const reopened = await repo.open(metadata, BACKGROUND_CONTEXT); - await reopened.close(BACKGROUND_CONTEXT); - }); - - test("can be passed to AgentHarness.create without local/remote branching", async () => { - const { remote } = await createRemoteSession("remote-harness"); - const model = { - id: "model", - name: "Model", - api: "test", - provider: "test", - baseUrl: "https://example.test", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 10_000, - maxTokens: 1_000, - } satisfies Model<"test">; - const { harness } = await AgentHarness.create( - { session: remote, models: createModels(), model, resources: {} }, - BACKGROUND_CONTEXT, - ); - - await expect(harness.getModel(BACKGROUND_CONTEXT)).resolves.toBeUndefined(); - await harness.close(BACKGROUND_CONTEXT); - }); -});