diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 088a5218a7..918fc01ee4 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -29,6 +29,10 @@ principal. An ordinary direct B consumer does not become that producer by supply The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal. Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. +A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). + +Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime. + ## Connections and recovery The shared Node connector owns the target grammar and each connection attempt, including a fresh @@ -52,6 +56,10 @@ Ordinary non-recoverable mutations are not automatically replayed. Existing ackn creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission RPC codes pass through unchanged, and connection/recovery behavior matches direct clients. +Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed. + +Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction. + Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8 channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed diff --git a/packages/client/src/agents/observation.ts b/packages/client/src/agents/observation.ts new file mode 100644 index 0000000000..5b595379f2 --- /dev/null +++ b/packages/client/src/agents/observation.ts @@ -0,0 +1,50 @@ +import type { DaemonClient } from "../daemon-client.js"; +import type { OwnedSubscription } from "../connection/index.js"; +type SubscriptionSnapshot = Awaited["ready"]>; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; + +/** Exact-ID observation over the ordinary owned agent directory stream. */ +export function observeAgent( + client: DaemonClient, + agentId: string, +): OwnedSubscription { + client.requireFleetSubagents(); + const source = client.observeAgents({ + includeBackground: true, + filter: { agentIds: [agentId], includeArchived: true, includeBackground: true }, + }); + const project = ( + snapshot: Awaited, + ): SubscriptionSnapshot => { + const agent = snapshot.entries.find((entry) => entry.agent.id === agentId)?.agent; + if (!agent) + throw Object.assign(new Error(`Agent ${agentId} was not found`), { code: "agent_not_found" }); + return { ...agent, subscriptionId: snapshot.subscriptionId }; + }; + const ready = source.ready.then(project).catch(async (error: unknown) => { + await source.release(); + throw error; + }); + void ready.catch(() => {}); + return { + get subscriptionId() { + return source.subscriptionId; + }, + ready, + subscribe(observer) { + return source.subscribe({ + snapshot(snapshot) { + try { + observer.snapshot(project(snapshot)); + } catch (error) { + observer.error?.(error); + void source.release(); + } + }, + update: observer.update, + error: observer.error, + }); + }, + release: () => source.release(), + }; +} diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts index fd9db1d9df..e68df1282c 100644 --- a/packages/client/src/creation/index.ts +++ b/packages/client/src/creation/index.ts @@ -168,7 +168,10 @@ export class CreationClient { subscribe: true, }); if (result.creation) this.receive(result.creation); - this.finish(operation, result); + this.finish(operation, { + ...result, + errorCode: result.creation?.errorCode ?? result.errorCode, + }); } catch (error) { if ( operation.settled || diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9bdd7a9e4c..c722fc0243 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -460,6 +460,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { cwd?: string; env?: CreateAgentRequestMessage["env"]; workspaceId?: string; + parent?: CreateAgentRequestMessage["parent"]; callerAgentId?: string; initialPrompt?: string; idempotencyKey?: string; @@ -1678,6 +1679,96 @@ export class DaemonClient { code: "unsupported_capability", }); } + requireFleetSubagents(): void { + if (this.lastServerInfoMessage?.features?.fleetSubagents !== true) + throw Object.assign(new Error("Update the host to use fleet subagents"), { + code: "unsupported_capability", + }); + } + observeAgentCreation(idempotencyKey: string) { + this.requireFleetSubagents(); + return this.observe("creation.subscribe.response", { + type: "creation.subscribe.request", + kind: "agent", + idempotencyKey, + }); + } + async cancelAgentCreation(input: { idempotencyKey: string; agentId: string }) { + this.requireFleetSubagents(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.create.cancel.request", ...input }, + responseType: "agent.create.cancel.response", + }) + ).cancellation; + } + async getAgentRun(agentId: string, submissionId: string) { + this.requireFleetSubagents(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.runs.get.request", agentId, submissionId }, + responseType: "agent.runs.get.response", + }) + ).run; + } + async createSubagent( + parentId: string, + input: import("@getpaseo/protocol/messages").SubagentCreateInput, + ) { + this.requireFleetSubagents(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.subagents.create.request", parentId, ...input }, + responseType: "agent.subagents.create.response", + }) + ).child; + } + async detachSubagent( + parentId: string, + child: import("@getpaseo/protocol/addresses").AgentAddress, + ) { + this.requireFleetSubagents(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.subagents.detach.request", parentId, child }, + responseType: "agent.subagents.detach.response", + }) + ).relationship; + } + async forgetSubagent( + parentId: string, + child: import("@getpaseo/protocol/addresses").AgentAddress, + ): Promise { + this.requireFleetSubagents(); + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.subagents.forget.request", parentId, child }, + responseType: "agent.subagents.forget.response", + }); + } + async listSubagents(parentId: string) { + this.requireFleetSubagents(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.subagents.list.request", parentId }, + responseType: "agent.subagents.list.response", + }) + ).directory; + } + observeSubagents( + parentId: string, + ): OwnedSubscription { + this.requireFleetSubagents(); + return this.owned.observe(async (accept) => { + const result = await this.sendCorrelatedSessionRequest({ + message: { type: "agent.subagents.list.request", parentId, observe: true }, + responseType: "agent.subagents.list.response", + }); + if (!result.subscriptionId) throw new Error("Host omitted subagent subscription identity"); + const snapshot = { ...result.directory, subscriptionId: result.subscriptionId }; + accept(snapshot); + return snapshot; + }); + } async listHosts(): Promise { this.requireConnectedHosts(); return ( @@ -2552,6 +2643,7 @@ export class DaemonClient { observeAgents( options: Omit = {}, ): OwnedSubscription { + if (options.filter?.agentIds) this.requireFleetSubagents(); if (options.includeBackground || options.filter?.includeBackground) this.requireBackgroundWorkspaces(); const { signal, requestId, timeout, ...query } = options; @@ -2585,6 +2677,7 @@ export class DaemonClient { if (options?.includeBackground || options?.filter?.includeBackground) this.requireBackgroundWorkspaces(); if (options?.subscribe) { + if (options.filter?.agentIds) this.requireFleetSubagents(); if (options.subscribe.subscriptionId !== undefined) throw new Error("Subscription IDs are assigned by the host"); const subscription = this.observeAgents(options); @@ -3094,12 +3187,16 @@ export class DaemonClient { } async createAgent(options: CreateAgentRequestOptions): Promise { + if (options.parent) this.requireFleetSubagents(); if (options.background !== undefined) this.requireBackgroundWorkspaces(); const result = await this.creations.createAgent({ ...options, config: resolveAgentConfig(options), }); - if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); + if (result.error || !result.agent) + throw Object.assign(new Error(result.error ?? "Agent creation failed"), { + code: result.errorCode, + }); return result.agent; } @@ -3115,6 +3212,7 @@ export class DaemonClient { config, ...(options.env ? { env: options.env } : {}), ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), + ...(options.parent !== undefined ? { parent: options.parent } : {}), ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), idempotencyKey: options.idempotencyKey, @@ -7270,6 +7368,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon onEvent: _onEvent, idempotencyKey: _idempotencyKey, clientMessageId: _clientMessageId, + parent: _parent, callerAgentId: _callerAgentId, outputSchema: _outputSchema, attachments: _attachments, diff --git a/packages/client/src/hosts/runtime.ts b/packages/client/src/hosts/runtime.ts index 451a6ff663..751d52675b 100644 --- a/packages/client/src/hosts/runtime.ts +++ b/packages/client/src/hosts/runtime.ts @@ -65,9 +65,13 @@ export class HostRuntime { return api.agents.ref(agentId); } + protected stopAcquisitions(): void { + this.disposed = true; + } + dispose(): Promise { if (this.disposal) return this.disposal; - this.disposed = true; + this.stopAcquisitions(); this.disposal = (async () => { const results = await Promise.allSettled([...this.scopes].map((scope) => scope.dispose())); // Acquisitions that finish after disposal release themselves before rejecting callers. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a26c535bb2..ce744fb695 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ +import { observeAgent } from "./agents/observation.js"; +import { safeRandomId } from "./daemon-client-transport-utils.js"; import { createHostActions, type PaseoHostActions } from "./hosts/index.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; export type { AgentAddress, WorkspaceAddress } from "@getpaseo/protocol/addresses"; @@ -9,6 +11,8 @@ export type { OwnedSubscription, SubscriptionObserver } from "./connection/index import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { + SubagentRelationship, + SubagentDirectory, AgentSnapshotPayload, CreationSnapshot, CreateAgentRequestMessage, @@ -252,7 +256,7 @@ export interface PaseoAgentCreateOptions { onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; - parent?: string | PaseoAgentHandle; + parent?: string | PaseoAgentHandle | AgentAddress; title?: PaseoAgentSessionConfig["title"]; env?: CreateAgentRequestMessage["env"]; prompt?: string; @@ -344,7 +348,28 @@ export interface PaseoAgentTimelineHandle { subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription; } +export type PaseoSubagentCreateOptions = Omit & { + serverId?: string; + workspaceId?: string; + cwd?: string; +}; +export interface PaseoSubagentActions { + detach(child: AgentAddress): Promise; + forget(child: AgentAddress): Promise; + create(input: PaseoSubagentCreateOptions): Promise; + list(): Promise; + observe(): OwnedSubscription; +} +export type { SubagentDirectory, SubagentRelationship } from "@getpaseo/protocol/messages"; +export type { AgentRunRecord, AgentRunCheckpoint } from "@getpaseo/protocol/agent-runs"; export interface PaseoAgentHandle { + observe(): OwnedSubscription; + readonly runs: { + get( + submissionId: string, + ): Promise; + }; + readonly subagents: PaseoSubagentActions; readonly id: string; readonly serverId: string; readonly address: AgentAddress; @@ -491,7 +516,17 @@ export interface PaseoConfigActions { ): Promise<{ requestId: string; config: MutableDaemonConfig }>; } +export interface PaseoCreationActions { + observeAgent(idempotencyKey: string): ReturnType; + /** Cancel only pre-admission creation; admitted work retains its existing receipt. */ + cancelAgent(input: { + idempotencyKey: string; + agentId: string; + }): Promise; +} + export interface PaseoApi { + readonly creations: PaseoCreationActions; readonly hosts: PaseoHostActions; dispose(): Promise; observeEvents: DaemonClient["observeEvents"]; @@ -534,9 +569,11 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { function toDaemonAgentCreateOptions( options: PaseoAgentCreateOptions, + serverId: string | null, placement?: { workspaceId: string; cwd: string }, ): CreateAgentRequestOptions { const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; + const parentAddress = normalizeParentAddress(parent, serverId); const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; const { provider, model } = parseProviderModel(providerModel); return { @@ -550,7 +587,11 @@ function toDaemonAgentCreateOptions( ...(providerOptions !== undefined ? { providerOptions } : {}), }, ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(parentAddress ? { parent: parentAddress } : {}), + ...(parentAddress?.serverId === serverId && + (typeof parent === "string" || (parent && "address" in parent)) + ? { callerAgentId: parentAddress.agentId } + : {}), ...(prompt !== undefined ? { initialPrompt: prompt } : {}), }; } @@ -588,17 +629,85 @@ export function createPaseoApi( workspaceListeners.delete(handler); }; }; + const hosts = createHostActions(daemonClient, { + own, + signal: lifetime.signal, + api: createPaseoApi, + }); + let subagentRuntime: import("./hosts/runtime.js").HostRuntime | undefined; const createAgentHandle = createAgentHandleFactory( daemonClient, listenAgents, (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), lifetime.signal, + (agentId) => own(() => observeAgent(daemonClient, agentId)), + (parentId) => ({ + detach: (child) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + return daemonClient.detachSubagent(parentId, child); + }, + forget: (child) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + return daemonClient.forgetSubagent(parentId, child); + }, + list: () => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + return daemonClient.listSubagents(parentId); + }, + observe: () => own(() => daemonClient.observeSubagents(parentId)), + create: async ({ serverId, idempotencyKey, ...input }) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + const normalized = toDaemonAgentCreateOptions( + { ...input, cwd: input.cwd ?? "" }, + daemonClient.getServerId(), + ); + const child = await daemonClient.createSubagent(parentId, { + serverId: serverId ?? requireServerIdentity(daemonClient.getServerId()), + idempotencyKey: idempotencyKey ?? safeRandomId(), + input: { ...normalized, labels: input.labels ?? {}, config: normalized.config! }, + }); + subagentRuntime ??= hosts.runtime(); + const handle = await subagentRuntime.agent(child); + await handle.refresh(); + return handle; + }, + }), ); const createAgent = async ( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); + const { parent, ...input } = options; + if ( + parent && + typeof parent !== "string" && + "subagents" in parent && + daemonClient.getLastServerInfoMessage()?.features?.fleetSubagents === true + ) { + return parent.subagents.create({ + ...input, + serverId: requireServerIdentity(daemonClient.getServerId()), + workspaceId: placement?.workspaceId, + }); + } + if ( + typeof parent === "string" && + daemonClient.getLastServerInfoMessage()?.features?.fleetSubagents === true + ) { + return createAgentHandle(parent).subagents.create({ + ...input, + workspaceId: placement?.workspaceId, + }); + } + const normalized = toDaemonAgentCreateOptions(options, daemonClient.getServerId(), placement); + // COMPAT(qualifiedSubagents): existing local parent creation on pre-fleet daemons. + // Added in v0.12; remove after 2027-04-20 once the host floor supports fleet subagents. + if ( + normalized.callerAgentId && + daemonClient.getLastServerInfoMessage()?.features?.fleetSubagents !== true + ) + delete normalized.parent; + const agent = await daemonClient.createAgent(normalized); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -705,7 +814,14 @@ export function createPaseoApi( return { dispose, - hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }), + creations: { + observeAgent: (key) => own(() => daemonClient.observeAgentCreation(key)), + cancelAgent: (input) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + return daemonClient.cancelAgentCreation(input); + }, + }, + hosts, observeEvents, terminals, projects: { @@ -723,7 +839,12 @@ export function createPaseoApi( openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), create: async ({ requestId, agent, ...options }) => { const result = await daemonClient.createWorkspace( - { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + { + ...options, + ...(agent + ? { agent: toDaemonAgentCreateOptions(agent, daemonClient.getServerId()) } + : {}), + }, requestId, ); if (result.error || !result.workspace) { @@ -872,6 +993,8 @@ function createAgentHandleFactory( listen: (handler: PaseoAgentUpdateHandler) => () => void, subscribeTimeline: DaemonClient["subscribeAgentTimeline"], signal: AbortSignal, + observeSnapshot: (agentId: string) => OwnedSubscription, + subagents: (parentId: string) => PaseoSubagentActions, ): AgentHandleFactory { const assertActive = () => { if (signal.aborted) throw new Error("Paseo API is disposed"); @@ -881,6 +1004,31 @@ function createAgentHandleFactory( let current = typeof agent === "string" ? null : agent; const handle: PaseoAgentHandle = { + observe: () => { + assertActive(); + const observation = observeSnapshot(id); + observation.subscribe({ + snapshot: (snapshot) => { + current = snapshot; + }, + update: (message) => { + if ( + message.type === "agent_update" && + message.payload.kind === "upsert" && + message.payload.agent.id === id + ) + current = message.payload.agent; + }, + }); + return observation; + }, + runs: { + get: (submissionId) => { + assertActive(); + return daemonClient.getAgentRun(id, submissionId); + }, + }, + subagents: subagents(id), id, get serverId() { const serverId = daemonClient.getServerId(); @@ -1053,8 +1201,20 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string { return typeof workspace === "string" ? workspace : workspace.id; } -function resolveAgentId(agent: string | PaseoAgentHandle): string { - return typeof agent === "string" ? agent : agent.id; +function normalizeParentAddress( + parent: PaseoAgentCreateOptions["parent"], + serverId: string | null, +): AgentAddress | undefined { + if (!parent) return undefined; + if (typeof parent === "string") + return { serverId: requireServerIdentity(serverId), agentId: parent }; + if ("address" in parent) return parent.address; + return parent; +} + +function requireServerIdentity(serverId: string | null): string { + if (!serverId) throw new Error("Connect to the host before addressing a parent agent"); + return serverId; } function parseProviderModel(selection: string): { provider: string; model: string } { diff --git a/packages/protocol/src/agent-lifecycle.ts b/packages/protocol/src/agent-lifecycle.ts index c259179418..c7e827b000 100644 --- a/packages/protocol/src/agent-lifecycle.ts +++ b/packages/protocol/src/agent-lifecycle.ts @@ -1,3 +1,4 @@ +import type { WorkspaceAddress } from "./addresses.js"; export const AGENT_LIFECYCLE_STATUSES = [ "initializing", "idle", @@ -7,3 +8,18 @@ export const AGENT_LIFECYCLE_STATUSES = [ ] as const; export type AgentLifecycleStatus = (typeof AGENT_LIFECYCLE_STATUSES)[number]; + +/** Archiving a parent preserves children in another workspace or an open tab. */ +export function shouldDetachFromArchivedParent(input: { + parentWorkspace: WorkspaceAddress | undefined; + childWorkspace: WorkspaceAddress | undefined; + hasOpenTab: boolean; +}): boolean { + const { parentWorkspace, childWorkspace } = input; + const differentWorkspace = + parentWorkspace !== undefined && + childWorkspace !== undefined && + (parentWorkspace.serverId !== childWorkspace.serverId || + parentWorkspace.workspaceId !== childWorkspace.workspaceId); + return differentWorkspace || input.hasOpenTab; +} diff --git a/packages/protocol/src/agent-runs.ts b/packages/protocol/src/agent-runs.ts new file mode 100644 index 0000000000..253d68e696 --- /dev/null +++ b/packages/protocol/src/agent-runs.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { AgentAddressSchema } from "./addresses.js"; + +export const AgentRunCheckpointSchema = z.object({ + sequence: z.number().int().positive(), + kind: z.enum([ + "interrupted", + "submitted", + "running", + "permission", + "resumed", + "completed", + "failed", + "canceled", + ]), + at: z.string(), + permissionRequestId: z.string().optional(), + result: z.string().optional(), + error: z.string().optional(), +}); +export type AgentRunCheckpoint = z.infer; +export const AgentRunRecordSchema = z.object({ + agent: AgentAddressSchema, + submissionId: z.string(), + turnId: z.string().nullable(), + durability: z.enum(["persisted", "pending"]).optional(), + status: z.enum([ + "interrupted", + "submitted", + "running", + "permission", + "completed", + "failed", + "canceled", + ]), + checkpoints: z.array(AgentRunCheckpointSchema), +}); +export type AgentRunRecord = z.infer; +export const AgentRunGetRequestSchema = z.object({ + type: z.literal("agent.runs.get.request"), + requestId: z.string(), + agentId: z.string(), + submissionId: z.string(), +}); +export const AgentRunGetResponseSchema = z.object({ + type: z.literal("agent.runs.get.response"), + payload: z.object({ requestId: z.string(), run: AgentRunRecordSchema.nullable() }), +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ba0a2e8393..5fa3306943 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,3 +1,5 @@ +import { AgentRunGetRequestSchema, AgentRunGetResponseSchema } from "./agent-runs.js"; +import { AgentAddressSchema, WorkspaceAddressSchema } from "./addresses.js"; import { AgentMessageSchema } from "./agent-message.js"; import { HostsListRequestSchema, @@ -830,6 +832,7 @@ const AgentActiveTurnPayloadSchema = z.object({ }); export const AgentSnapshotPayloadSchema = z.object({ + parent: AgentAddressSchema.nullable().optional(), id: z.string(), provider: AgentProviderSchema, cwd: z.string(), @@ -865,6 +868,7 @@ export const AgentSnapshotPayloadSchema = z.object({ export type AgentSnapshotPayload = z.infer; export const AgentListItemPayloadSchema = z.object({ + parent: AgentAddressSchema.nullable().optional(), id: z.string(), shortId: z.string(), title: z.string().nullable(), @@ -925,6 +929,7 @@ export const AudioPlayedMessageSchema = z.object({ }); const AgentDirectoryFilterSchema = z.object({ + agentIds: z.array(z.string()).optional(), labels: z.record(z.string(), z.string()).optional(), projectKeys: z.array(z.string()).optional(), statuses: z.array(AgentStatusSchema).optional(), @@ -1725,6 +1730,7 @@ export const CreateAgentWorktreeTargetSchema = z.discriminatedUnion("mode", [ export type CreateAgentWorktreeTarget = z.infer; export const CreateAgentRequestMessageSchema = z.object({ + parent: AgentAddressSchema.nullable().optional(), type: z.literal("create_agent_request"), // Legacy create_agent_request uses a separate initial-message receipt when keyed. idempotencyKey: z.string().min(1).max(512).optional(), @@ -3220,7 +3226,106 @@ export const SubscriptionReleaseResponseSchema = z.object({ payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), }); +export const SubagentRelationshipSchema = z.object({ + id: z.string(), + parent: AgentAddressSchema, + child: AgentAddressSchema, + workspace: WorkspaceAddressSchema.optional(), + phase: z.enum([ + "creating", + "attached", + "detach_pending", + "lifecycle_pending", + "canceled", + "detached", + "archived", + "forgotten", + ]), + snapshot: AgentSnapshotPayloadSchema.optional(), + observedAt: z.string().nullable(), + freshness: z.enum(["current", "stale", "unavailable"]), + error: z.object({ code: z.string(), message: z.string() }).nullable(), +}); +export type SubagentRelationship = z.infer; +export const SubagentDirectorySchema = z.object({ + parent: AgentAddressSchema, + rows: z.array(SubagentRelationshipSchema), +}); +export type SubagentDirectory = z.infer; +export const SubagentCreateInputSchema = z.object({ + serverId: z.string().min(1), + idempotencyKey: z.string().min(1).max(512), + input: CreateAgentRequestMessageSchema.omit({ + type: true, + requestId: true, + callerAgentId: true, + parent: true, + }).extend({ agentId: z.string().uuid().optional() }), +}); +export type SubagentCreateInput = z.infer; +export const SubagentsCreateRequestSchema = z.object({ + type: z.literal("agent.subagents.create.request"), + requestId: z.string(), + parentId: z.string(), + ...SubagentCreateInputSchema.shape, +}); +export const SubagentsCreateResponseSchema = z.object({ + type: z.literal("agent.subagents.create.response"), + payload: z.object({ requestId: z.string(), child: AgentAddressSchema }), +}); +export const SubagentsListRequestSchema = z.object({ + type: z.literal("agent.subagents.list.request"), + requestId: z.string(), + parentId: z.string(), + observe: z.boolean().optional(), +}); +export const SubagentsListResponseSchema = z.object({ + type: z.literal("agent.subagents.list.response"), + payload: z.object({ + requestId: z.string(), + directory: SubagentDirectorySchema, + subscriptionId: z.string().optional(), + }), +}); +export const SubagentsDetachRequestSchema = z.object({ + type: z.literal("agent.subagents.detach.request"), + requestId: z.string(), + parentId: z.string(), + child: AgentAddressSchema, +}); +export const SubagentsDetachResponseSchema = z.object({ + type: z.literal("agent.subagents.detach.response"), + payload: z.object({ requestId: z.string(), relationship: SubagentRelationshipSchema }), +}); +export const SubagentsForgetRequestSchema = z.object({ + type: z.literal("agent.subagents.forget.request"), + requestId: z.string(), + parentId: z.string(), + child: AgentAddressSchema, +}); +export const SubagentsForgetResponseSchema = z.object({ + type: z.literal("agent.subagents.forget.response"), + payload: z.object({ requestId: z.string() }), +}); +export const SubagentsChangedSchema = z.object({ + type: z.literal("agent.subagents.changed"), + payload: z.object({ subscriptionId: z.string(), directory: SubagentDirectorySchema }), +}); + +export const AgentCreationCancelRequestSchema = z.object({ + type: z.literal("agent.create.cancel.request"), + requestId: z.string(), + idempotencyKey: z.string().min(1), + agentId: z.string().uuid(), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + AgentCreationCancelRequestSchema, + AgentRunGetRequestSchema, + SubagentsDetachRequestSchema, + SubagentsForgetRequestSchema, + SubagentsCreateRequestSchema, + SubagentsListRequestSchema, HostsListRequestSchema, HostsAddRequestSchema, HostsUpdateRequestSchema, @@ -3623,6 +3728,7 @@ export const ServerInfoStatusPayloadSchema = z backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), connectedHosts: z.boolean().optional(), + fleetSubagents: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. @@ -4938,6 +5044,19 @@ export const CreationSnapshotSchema = z.object({ failedStage: z.enum(["workspace", "agent", "prompt"]).optional(), outcomeUnknown: z.boolean().optional(), }); +export const AgentCreationCancellationSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("canceled"), reservedAgent: AgentAddressSchema }), + z.object({ + status: z.literal("admitted"), + reservedAgent: AgentAddressSchema, + creation: CreationSnapshotSchema, + }), +]); +export type AgentCreationCancellation = z.infer; +export const AgentCreationCancelResponseSchema = z.object({ + type: z.literal("agent.create.cancel.response"), + payload: z.object({ requestId: z.string(), cancellation: AgentCreationCancellationSchema }), +}); export type CreationSnapshot = z.infer; export type AgentCreateRequest = z.infer; export type WorkspaceInitialAgent = z.infer; @@ -4964,6 +5083,7 @@ export const AgentCreateResponseSchema = z.object({ requestId: z.string(), agent: AgentSnapshotPayloadSchema.nullable(), error: z.string().nullable(), + errorCode: z.string().optional(), creation: CreationSnapshotSchema.optional(), }), }); @@ -6903,6 +7023,13 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + AgentCreationCancelResponseSchema, + AgentRunGetResponseSchema, + SubagentsDetachResponseSchema, + SubagentsForgetResponseSchema, + SubagentsCreateResponseSchema, + SubagentsListResponseSchema, + SubagentsChangedSchema, HostsListResponseSchema, HostsChangedSchema, HostsAddResponseSchema, diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index d75fa8739a..5595c31d9a 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -48,6 +48,7 @@ test("loads archived records for history and active records with the interactive isAvailable: async () => await baseClient.isAvailable(), }; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -87,6 +88,7 @@ test("resuming a stored agent keeps its unread flag and its last-activity time", const logger = createTestLogger(); const storage = new AgentStorage(path.join(root, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger, @@ -149,6 +151,7 @@ test("loads an archived agent's history after its working directory is removed", const logger = createTestLogger(); const storage = new AgentStorage(path.join(root, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger, diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 761f1008bd..81277e3f42 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -117,7 +117,11 @@ export async function ensureAgentLoaded( handle, buildConfigOverrides(record), agentId, - { ...extractTimestamps(record), attention: extractAttention(record) }, + { + ...extractTimestamps(record), + parent: record.parent, + attention: extractAttention(record), + }, record.archivedAt ? { purpose: "history" } : undefined, ); deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); @@ -134,6 +138,7 @@ export async function ensureAgentLoaded( snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels, workspaceId: record.workspaceId, + parent: record.parent, owner: record.owner, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 9dc194fa11..4542f80a6b 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -247,6 +247,7 @@ function createHarness(options?: { provider?: AgentProvider }): Harness { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stream-coalescing-")); const client = new TestAgentClient(options?.provider ?? "codex"); const manager = new AgentManager({ + serverId: "srv_test", clients: { [client.provider]: client }, idFactory: createIdFactory(), logger: createTestLogger(), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 44e38a344f..5a5c3a464e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -650,7 +650,7 @@ async function startAndSteerThroughManager( return session; } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -672,6 +672,7 @@ test("uses an injected timeline store without making it a production requirement const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-")); const store = new RecordingTimelineStore(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, durableTimelineStore: store, logger, @@ -727,6 +728,7 @@ test("refreshing an agent replaces the injected timeline store instead of append } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new HistoryClient() }, durableTimelineStore: store, logger, @@ -790,6 +792,7 @@ test("a failed history replay leaves the committed timeline intact", async () => } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new FlakyHistoryClient() }, durableTimelineStore: store, logger, @@ -843,6 +846,7 @@ test("retries provider history hydration after a stream failure", async () => { } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new (class extends TestAgentClient { override async resumeSession( @@ -913,6 +917,7 @@ test("orders an accepted steer before output emitted while acknowledgement is pe const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() }); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-order-")); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new (class extends TestAgentClient { override async createSession(): Promise { @@ -981,6 +986,7 @@ test("orders buffered pre-steer output before an immediately accepted steer", as const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-buffer-order-")); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new (class extends TestAgentClient { override async createSession(): Promise { @@ -1050,6 +1056,7 @@ test("orders a concurrent replacement after a pending accepted steer", async () const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() }); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-replace-order-")); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new (class extends TestAgentClient { override async createSession(): Promise { @@ -1121,6 +1128,7 @@ test("does not replace a newer foreground turn after unavailable steer fallback } })(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, beforeSteerUnavailableFallback: async () => { entered.resolve(); @@ -1187,7 +1195,7 @@ test("steers a tracked autonomous turn without creating a replacement run", asyn return session; } })(); - const manager = new AgentManager({ clients: { claude: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { claude: client }, logger }); let agentId: string | null = null; try { @@ -1267,7 +1275,7 @@ test("ambiguous steer failure leaves the active turn untouched", async () => { return session; } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); let agentId: string | null = null; try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -1306,7 +1314,7 @@ test("steering records concurrent early echoes as canonical submitted prompts", return session; } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); let agentId: string | null = null; try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -1412,6 +1420,7 @@ async function createControlledInterruptFixture(options: { } })(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, @@ -1650,6 +1659,7 @@ const logger = createTestLogger(); test("does not register a session that finishes starting after shutdown begins", async () => { const client = new HeldAgentCreationClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, logger, idFactory: () => "00000000-0000-4000-8000-000000000100", @@ -1678,6 +1688,7 @@ test("does not register a session that finishes starting after shutdown begins", test("flush waits for rejected session cleanup that starts after shutdown", async () => { const client = new HeldAgentCreationAndCloseClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, logger, idFactory: () => "00000000-0000-4000-8000-000000000098", @@ -1721,6 +1732,7 @@ test("does not persist an initializing session after shutdown closes it", async const client = new HeldRuntimeInfoClient(); const agentId = "00000000-0000-4000-8000-000000000099"; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -1760,6 +1772,7 @@ test("reload leaves a closed durable snapshot when shutdown starts during the sw const client = new HeldReloadCloseClient(); const agentId = "00000000-0000-4000-8000-000000000097"; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -1810,6 +1823,7 @@ test("reload does not create a replacement when the closed snapshot cannot be pe const client = new HeldReloadCloseClient(); const agentId = "00000000-0000-4000-8000-000000000096"; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -1857,6 +1871,7 @@ test("normalizeConfig injects the provider default model while leaving mode omit const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -1881,6 +1896,7 @@ test("normalizeConfig injects the provider default model while leaving mode omit test("normalizeConfig leaves Claude mode omitted", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-claude-default-test-")); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: new TestAgentClient("claude") }, logger, }); @@ -1903,10 +1919,7 @@ test("normalizeConfig does not ask the provider to synthesize an omitted mode", } } const client = new CapabilityAwareClient(); - const manager = new AgentManager({ - clients: { codex: client }, - logger, - }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, @@ -1921,6 +1934,7 @@ test("createAgent forwards request env into the spawned provider process", async const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-")); const client = new EnvProbeAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -1957,6 +1971,7 @@ test("normalizeConfig strips legacy 'default' model id", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -2005,6 +2020,7 @@ test("listDraftCommands returns no commands without guessing a missing model", a } const client = new DraftCommandClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2052,6 +2068,7 @@ test("listDraftCommands uses explicit model config without default model fetchin } const client = new DraftCommandClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2103,6 +2120,7 @@ test("listDraftFeatures does not start a fallback session without a model", asyn } const client = new DraftFeatureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2150,6 +2168,7 @@ test("listDraftFeatures uses client feature listing without a model", async () = } const client = new DraftFeatureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2199,6 +2218,7 @@ test("listDraftFeatures uses explicit model config without default model fetchin } const client = new DraftFeatureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2230,6 +2250,7 @@ test("createAgent injects daemon append system prompt at runtime only", async () const storage = new AgentStorage(storagePath, logger); const client = new TestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2263,6 +2284,7 @@ test("daemon append system prompt is injected into Pi configs", async () => { const storage = new AgentStorage(storagePath, logger); const client = new TestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { pi: client as unknown as AgentClient, }, @@ -2385,6 +2407,7 @@ test("setAgentMode persists the selected mode across session reload", async () = } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ModeAwareClient(), }, @@ -2439,7 +2462,12 @@ test("reload releases the original writer before resuming the same session", asy } } const client = new ExclusiveWriterClient(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, @@ -2463,7 +2491,12 @@ test("opening during reload waits for the replacement", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-open-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new HeldReloadCloseClient(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, @@ -2496,7 +2529,12 @@ test("closing during reload leaves the replacement closed", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new HeldReloadCloseClient(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, @@ -2521,6 +2559,7 @@ test("retrying a timed-out reload waits for the original close to finish", async const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new HeldReloadCloseClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -2558,7 +2597,12 @@ test("failed reload retains the closed agent for a later resume", async () => { } } const client = new FailingResumeClient(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Keep me" }, @@ -2629,6 +2673,7 @@ test.each(["hang", "reject"])( const client = new HangingCloseClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2801,6 +2846,7 @@ test("listProviderAvailability uses registered client keys, including custom pro }; const manager = new AgentManager({ + serverId: "srv_test", clients: { zai: customClient, }, @@ -2837,6 +2883,7 @@ test("createAgent passes daemon launch env through the provider launch context", const client = new CaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2888,6 +2935,7 @@ test("createAgent passes persistSession to provider create options", async () => const client = new CaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -2915,6 +2963,7 @@ test("createAgent persists workspaceId on the stored record and emits it in the const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -2959,6 +3008,7 @@ test("createAgent injects paseo MCP server only into provider launch config", as const client = new CaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3044,6 +3094,7 @@ test("createAgent closes and rejects a provider session that cannot honor MCP se const agentId = "00000000-0000-4000-8000-000000000107"; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnsupportedMcpClient() }, registry: storage, logger, @@ -3090,6 +3141,7 @@ test("resumeAgentFromPersistence closes and rejects a session that cannot honor const agentId = "00000000-0000-4000-8000-000000000108"; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnsupportedResumeClient() }, registry: storage, logger, @@ -3139,6 +3191,7 @@ test("reloadAgentSession preserves the live session when its replacement cannot let paseoToolPolicy = { disabledTools: ["list_agents"] }; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnsupportedReloadClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, @@ -3207,6 +3260,7 @@ test("createAgent passes native Paseo tools through launch context without inter const client = new NativeToolsClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3271,6 +3325,7 @@ test("createAgent allows best-effort internal MCP when the provider session repo const client = new CaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3338,6 +3393,7 @@ test("uses each provider's current policy for new sessions and snapshots it by a }, }; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex, claude }, registry: storage, logger, @@ -3418,6 +3474,7 @@ test("keeps the global Paseo-tools gate outside provider policy and MCP injectio const enabledClient = new McpClient(); const enabledManager = new AgentManager({ + serverId: "srv_test", clients: { codex: enabledClient }, registry: storage, logger, @@ -3438,6 +3495,7 @@ test("keeps the global Paseo-tools gate outside provider policy and MCP injectio const disabledClient = new McpClient(); let catalogFactoryCalls = 0; const disabledManager = new AgentManager({ + serverId: "srv_test", clients: { codex: disabledClient }, registry: storage, logger, @@ -3468,6 +3526,7 @@ test("resumeAgentFromPersistence replaces stored internal paseo MCP with current const storage = new AgentStorage(storagePath, logger); const client = new McpCapableTestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3522,6 +3581,7 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in const storage = new AgentStorage(storagePath, logger); const client = new TestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3566,6 +3626,7 @@ test("createAgent preserves a user-provided paseo MCP config", async () => { const client = new CaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -3604,6 +3665,7 @@ test("createAgent fails when cwd does not exist", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -3628,6 +3690,7 @@ test("createAgent reports configured providers when provider is unknown", async const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -3659,6 +3722,7 @@ test("createAgent reports available providers when selected provider is unavaila } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnavailableCodexClient(), claude: new TestAgentClient(), @@ -3702,6 +3766,7 @@ test("createAgent rejects a disabled provider without creating a session", async }, } satisfies Partial>>; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: disabledClient, }, @@ -3730,6 +3795,7 @@ test("updateProviderRegistry re-enables a previously disabled provider", async ( const storage = new AgentStorage(storagePath, logger); const client = new TestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, providerDefinitions: { codex: { enabled: false }, @@ -3761,6 +3827,7 @@ test("updateProviderRegistry disables a previously enabled provider", async () = const storage = new AgentStorage(storagePath, logger); const client = new TestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, providerDefinitions: { codex: { enabled: true }, @@ -3786,6 +3853,7 @@ test("updateProviderRegistry registers a previously unknown provider", async () const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: {}, providerDefinitions: {}, registry: storage, @@ -3822,6 +3890,7 @@ test("updateProviderRegistry removes providers omitted from the next registry", const removedClient = new RemovedProviderClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), [removedProvider]: removedClient }, providerDefinitions: { codex: { enabled: true }, @@ -3873,6 +3942,7 @@ test("retires loaded agents when their plugin provider is replaced", async () => const original = new OriginalPluginClient(); const replacement = new TestAgentClient(provider); const manager = new AgentManager({ + serverId: "srv_test", clients: { [provider]: original }, providerDefinitions: { [provider]: { enabled: true } }, registry: storage, @@ -3939,6 +4009,7 @@ test("a prompt after provider replacement reopens the stale session", async () = const staleClient = new StalePluginClient(); const replacement = new ResumeTrackingTestAgentClient(provider); const manager = new AgentManager({ + serverId: "srv_test", clients: { [provider]: staleClient }, providerDefinitions: { [provider]: { enabled: true } }, registry: storage, @@ -4017,6 +4088,7 @@ test("a replacement prompt recovers when the retired session fails to start", as const replacement = new ResumeTrackingTestAgentClient(provider); const manager = new AgentManager({ + serverId: "srv_test", clients: { [provider]: new StaleReplacementClient() }, providerDefinitions: { [provider]: { enabled: true } }, registry: storage, @@ -4067,6 +4139,7 @@ test("createAgent passes explicit model strings through to the provider", async } const client = new CaptureModelClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -4143,6 +4216,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p const client = new ResumeCaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -4291,6 +4365,7 @@ test("importProviderSession imports the selected session without listing and pub const client = new ImportClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -4394,6 +4469,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co const client = new ReloadCaptureClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -4485,6 +4561,7 @@ test("reloadAgentSession preserves timeline and does not force history replay", } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new HistoryProbeClient(), }, @@ -4543,6 +4620,7 @@ test("reloadAgentSession clears provider children before rehydrating from disk", } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProviderChildClient() }, registry: storage, logger, @@ -4584,6 +4662,7 @@ test("reloadAgentSession terminalizes running provider children when preserving } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProviderChildClient() }, registry: storage, logger, @@ -4629,6 +4708,7 @@ test("hydrateTimelineFromProvider restores and broadcasts provider children from } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProviderChildHistoryClient() }, registry: storage, logger, @@ -4682,6 +4762,7 @@ test("force provider hydration removes children absent from current history", as } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProviderChildForceClient() }, registry: storage, logger, @@ -4720,6 +4801,7 @@ test("reloadAgentSession preserves current title when config title is unset", as const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -4754,6 +4836,7 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -4790,6 +4873,7 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -4833,6 +4917,7 @@ test("persists live mode, model, and thinking changes without an external snapsh const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -4890,6 +4975,7 @@ test("model changes persist the resolved thinking and fresh provider handle befo } const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ModelClient() }, registry: storage, logger, @@ -4939,6 +5025,7 @@ test("later explicit config mutations win over events emitted by earlier mutatio } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ConfigMutationClient() }, logger, idFactory: () => "00000000-0000-4000-8000-000000000134", @@ -4981,6 +5068,7 @@ test("setAgentThinkingOption adopts the session's effective clamped level", asyn const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ClampingClient() }, registry: storage, logger, @@ -5023,6 +5111,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnreadableStateClient() }, registry: storage, logger, @@ -5060,6 +5149,7 @@ test("session config drift events update state through the stream channel", asyn } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ConfigEventClient(), }, @@ -5141,6 +5231,7 @@ test("setLabels merges and persists labels", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5174,6 +5265,7 @@ test("detachAgent removes relationship lifecycle labels from a live agent and em const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5234,6 +5326,7 @@ test("detachAgent removes relationship lifecycle labels from a stored-only agent const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5282,6 +5375,7 @@ test("archiveAgent does not cascade to a detached former child", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5320,6 +5414,7 @@ test("runAgent persists finished attention and idle status without an external s const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5353,6 +5448,7 @@ test("archiveSnapshot clears persisted attention and normalizes running status", const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5402,6 +5498,7 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -5440,6 +5537,7 @@ test("markAgentUnread dispatches stored attention to every subscriber", async () const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -5602,6 +5700,7 @@ test("reloadAgentSession cancels active run and resumes existing session once th const client = new DelayedPersistenceClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -5655,6 +5754,7 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5731,6 +5831,7 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5775,6 +5876,7 @@ test("getAgent does not expose committed history internals once manager owns the const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5832,6 +5934,7 @@ test("streams coalesced assistant chunks and retains the projected message", asy const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new StreamingAssistantClient(), }, @@ -5933,6 +6036,7 @@ test("fetchTimeline supports older-history pagination with before seq", async () const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -5997,6 +6101,7 @@ test("does not trim committed history", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6104,6 +6209,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ChunkedAssistantHistoryClient(), }, @@ -6186,6 +6292,7 @@ test("hydrateTimeline preserves reasoning between assistant chunks", async () => } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ReasoningInterleavedHistoryClient(), }, @@ -6220,6 +6327,7 @@ test("createAgent fails when generated agent ID is not a UUID", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6245,6 +6353,7 @@ test("createAgent fails when explicit agent ID is not a UUID", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6270,6 +6379,7 @@ test("createAgent persists provided title before returning", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6301,6 +6411,7 @@ test("createAgent populates runtimeInfo after session creation", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6330,6 +6441,7 @@ test("runAgent refreshes runtimeInfo after completion", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6381,6 +6493,7 @@ test("waitForAgentEvent does not resolve idle until foreground turn is finalized } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new SlowTerminalClient(), }, @@ -6431,6 +6544,7 @@ test("waitForAgentRunStart resolves while a foreground run is still only pending const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -6500,6 +6614,7 @@ test("waitForAgentRunStart ignores a prior turn error while the next run starts" } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ErrorThenResumeClient() }, registry: storage, logger, @@ -6587,6 +6702,7 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ReplaceRunClient(), }, @@ -6716,6 +6832,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new StaleReplacementClient(), }, @@ -6839,6 +6956,7 @@ test("applies live autonomous events and preserves usage omitted from completion } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new LiveEventClient(), }, @@ -6944,6 +7062,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new LiveEventClient() }, registry: storage, logger, @@ -7045,6 +7164,7 @@ test("preserves terminal fallback when no active turn identity was observed", as } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new LiveEventClient() }, registry: storage, logger, @@ -7101,6 +7221,7 @@ test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", const client = new LiveInterruptClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -7192,6 +7313,7 @@ test("failed replacement cancellation preserves an autonomous running state", as const client = new RejectingLiveInterruptClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -7240,6 +7362,7 @@ test("waitForAgentEvent waitForActive resolves for autonomous live-event run", a } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new LiveEventClient(), }, @@ -7303,6 +7426,7 @@ test("autonomous events arriving during foreground run are processed via subscri } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ForegroundClient(), }, @@ -7419,6 +7543,7 @@ test("subscribe error isolation: throwing subscriber does not break event flow", } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new IsolationClient(), }, @@ -7489,6 +7614,7 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7645,6 +7771,7 @@ test("runAgent assembles finalText from trailing assistant chunks", async () => } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ChunkedAssistantClient(), }, @@ -7676,6 +7803,7 @@ test("listAgents excludes internal agents", async () => { ]; let agentCounter = 0; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7725,6 +7853,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help const storage = new AgentStorage(join(workdir, "agents"), logger); const beforeHookCalls: string[] = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -7754,6 +7883,7 @@ test("global subscribers receive internal agent events only when they opt in", a const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -7793,6 +7923,7 @@ test("getAgent returns internal agents by ID", async () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7823,6 +7954,7 @@ test("archiveAgent closes an internal agent without writing a record", async () const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7870,6 +8002,7 @@ test("archiveAgent on an internal parent still archives its attached children", const ids = [parentAgentId, childAgentId]; let agentCounter = 0; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7906,6 +8039,7 @@ test("archiveAgent on a parent archives its live internal children", async () => const ids = [parentAgentId, childAgentId]; let agentCounter = 0; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -7945,6 +8079,7 @@ test("subscribe does not emit state events for internal agents to global subscri ]; let agentCounter = 0; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8000,6 +8135,7 @@ test("subscribe hides provider subagents of internal parents from global subscri } } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new InternalProviderChildClient() }, registry: storage, logger, @@ -8056,6 +8192,7 @@ test("subscribe emits state events for internal agents when subscribed by agentI const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8092,6 +8229,7 @@ test("subscribe emits state events for internal agents when subscribed by agentI test("subscribe fails when filter agentId is not a UUID", () => { const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8112,6 +8250,7 @@ test("onAgentAttention is not called for internal agents", async () => { const storage = new AgentStorage(storagePath, logger); const attentionCalls: string[] = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8148,6 +8287,7 @@ test("onAgentAttention is not called for delegated child agents", async () => { const storage = new AgentStorage(storagePath, logger); const attentionCalls: string[] = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8221,6 +8361,7 @@ test("clearAgentAttention on errored agent stays cleared until a new error trans const attentionReasons: Array<"finished" | "error" | "permission"> = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new FailingClient(), }, @@ -8327,6 +8468,7 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new FailsOnceClient(), }, @@ -8392,6 +8534,7 @@ test("acknowledged cancellation settles a pending run before it has a turn id", } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new PendingStartClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, @@ -8430,6 +8573,7 @@ test("archiveAgent persists archivedAt and updatedAt before emitting closed stat const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8481,6 +8625,7 @@ test("fires onAgentArchived for archived parent and cascaded children", async () const storage = new AgentStorage(storagePath, logger); const archivedIds: string[] = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8514,6 +8659,7 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => { const storage = new AgentStorage(storagePath, logger); const archivedIds: string[] = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8562,7 +8708,12 @@ test("native archive and restore release the loaded session writer", async () => } const client = new WriterClient(); const storage = new AgentStorage(join(workdir, "agents"), logger); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, @@ -8590,6 +8741,7 @@ test("unarchiveSnapshot skips native provider unarchive for active records", asy const storage = new AgentStorage(storagePath, logger); const client = new NativeArchiveRecordingClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -8617,6 +8769,7 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi const storage = new AgentStorage(storagePath, logger); const client = new NativeArchiveRecordingClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -8658,6 +8811,7 @@ test("unarchiveSnapshotByHandle unarchives native provider storage for the match const storage = new AgentStorage(storagePath, logger); const client = new NativeArchiveRecordingClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -8691,6 +8845,7 @@ test("unarchiveSnapshot keeps the stored record archived when native unarchive f const storage = new AgentStorage(storagePath, logger); const client = new NativeArchiveRecordingClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -8720,6 +8875,7 @@ test("archiveAgent cascade archives in-memory children with the full archive con const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -8770,6 +8926,7 @@ test("archiveAgent detaches an open same-workspace child instead of cascading", const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8806,6 +8963,7 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed" const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8859,6 +9017,7 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async ( const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8910,6 +9069,7 @@ test("archiveAgent cannot overtake a received child open-tab update", async () = const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -8978,6 +9138,7 @@ test("archiveAgent cascade closes a running child runtime", async () => { const client = new RunningChildClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -9036,6 +9197,7 @@ test("archiveAgent cascade archives off-memory children with the full archive co const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -9075,6 +9237,7 @@ test("archiveAgent cascade notifies subscribers for in-memory and off-memory chi const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -9153,6 +9316,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () => const storage = new FailingChildArchiveStorage(storagePath, logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -9226,6 +9390,7 @@ test("turn_failed emits a system error assistant timeline message and keeps erro } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TurnFailedClient(), }, @@ -9304,6 +9469,7 @@ test("turn_failed surfaces provider code and diagnostic in system error message" } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new DetailedFailureClient(), }, @@ -9396,6 +9562,7 @@ test("permission request notifies once without forcing unread attention state", const attentionReasons: Array<"finished" | "error" | "permission"> = []; const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new PermissionClient(), }, @@ -9535,6 +9702,7 @@ test("respondToPermission updates currentModeId after plan approval", async () = const client = new PlanModeTestClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client, }, @@ -9649,6 +9817,7 @@ test("respondToPermission refreshes features and runtime info after provider-man } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new RefreshingPermissionClient(), }, @@ -9762,6 +9931,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new OrderedPermissionClient(), }, @@ -9931,6 +10101,7 @@ test("close during in-flight stream does not clear persistence sessionId", async } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CloseRaceClient(), }, @@ -9974,6 +10145,7 @@ test("closeAgent persists one final closed snapshot", async () => { const storage = new AgentStorage(storagePath, logger); const applySnapshotSpy = vi.spyOn(storage, "applySnapshot"); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient(), }, @@ -10032,7 +10204,7 @@ test("idle agents remain resident until an explicit lifecycle action closes them return super.resumeSession(handle, config, launchContext); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10060,6 +10232,7 @@ test("archiving a closed parent still cascades to its managed children", async ( const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -10097,6 +10270,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-load-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TestAgentClient() }, registry: storage, logger, @@ -10139,7 +10313,12 @@ test("a stored-only archive waits for an in-flight resume and then closes it", a return super.resumeSession(handle, config, launchContext); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10183,7 +10362,12 @@ test("a stored-only archive closes a shared resume before a later protected load return super.resumeSession(handle, config, launchContext); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10240,7 +10424,12 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy })({ provider: "codex", cwd: config?.cwd ?? workdir }); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10288,7 +10477,12 @@ test("explicit close cancels running provider subagents before resume", async () const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new SessionRecordingAgentClient(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10372,7 +10566,12 @@ test("load waits for an in-flight explicit close and creates one resumed runtime return super.resumeSession(handle, config); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent( @@ -10422,7 +10621,7 @@ test("concurrent explicit closes tear down the runtime once", async () => { })(config); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); try { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -10454,7 +10653,12 @@ test("provider close failure retains the runtime for cleanup instead of allowing })(config); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const created = await manager.createAgent( @@ -10526,6 +10730,7 @@ test("hydrateTimeline keeps provider user_message items when no canonical user h } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new HistoryUserMessageClient(), }, @@ -10591,6 +10796,7 @@ test("hydrateTimeline preserves provider replay timestamps and marks missing one } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new TimestampedHistoryClient(), }, @@ -10662,6 +10868,7 @@ test("provider user_message is recorded from the live stream", async () => { } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new UnexpectedUserMsgClient() }, registry: storage, logger, @@ -10752,6 +10959,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi const client = new SubmittedUserMessageClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, registry: storage, logger, @@ -10879,6 +11087,7 @@ test("authoritative timeline records a daemon-handled submitted prompt before it } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new DaemonHandledPromptClient() }, registry: storage, logger, @@ -10944,7 +11153,7 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en return new SlashCommandSession(config); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -11006,6 +11215,7 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli } const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new StaleForegroundClient() }, registry: storage, logger, @@ -11105,6 +11315,7 @@ test.each([ const includedClient = new RecordingPersistedAgentsClient(includedProvider); const skippedClient = new RecordingPersistedAgentsClient(skippedProvider); const manager = new AgentManager({ + serverId: "srv_test", clients: { [includedProvider]: includedClient, [skippedProvider]: skippedClient }, providerDefinitions, logger, @@ -11122,6 +11333,7 @@ test("listImportableSessions includes derived providers that list persisted agen const claudeClient = new RecordingPersistedAgentsClient("claude"); const ompClient = new RecordingPersistedAgentsClient("omp"); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: claudeClient, omp: ompClient }, providerDefinitions: { claude: { enabled: true, derivedFromProviderId: null }, @@ -11141,6 +11353,7 @@ test("listImportableSessions narrows to the providerFilter when supplied", async const claudeClient = new RecordingPersistedAgentsClient("claude"); const codexClient = new RecordingPersistedAgentsClient("codex"); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: claudeClient, codex: codexClient }, providerDefinitions: { claude: { enabled: true, derivedFromProviderId: null }, @@ -11170,6 +11383,7 @@ test("listImportableSessions skips providers that lack supportsSessionListing ev }); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: listableClient, acp: nonListableClient }, providerDefinitions: { claude: { enabled: true, derivedFromProviderId: null }, @@ -11196,6 +11410,7 @@ test("listImportableSessions returns healthy rows alongside thrown and timed-out const hangingClient = new RecordingPersistedAgentsClient("pi"); hangingClient.listImportableSessions = async () => await new Promise(() => undefined); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: healthyClient, codex: failingClient, pi: hangingClient }, providerDefinitions: { claude: { enabled: true, derivedFromProviderId: null }, @@ -11278,6 +11493,7 @@ test("listImportableSessions searches every provider result before global rankin }, ]; const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: client }, providerDefinitions: { claude: { enabled: true, derivedFromProviderId: null }, @@ -11315,6 +11531,7 @@ test("user_message events wrapping a paseo-system envelope are not added to the }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex }, registry: storage, logger, @@ -11356,6 +11573,7 @@ test("user_message events wrapping a paseo-system envelope are not restored duri }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex }, registry: storage, logger, @@ -11430,6 +11648,7 @@ test("onWorkspaceStateMayHaveChanged is called when a completed shell tool call }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex }, registry: storage, logger, @@ -11466,6 +11685,7 @@ test("onWorkspaceStateMayHaveChanged is not called for non-shell tool calls", as }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex }, registry: storage, logger, @@ -11501,6 +11721,7 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex }, registry: storage, logger, @@ -11538,7 +11759,12 @@ test("concurrent native restores run once before resuming the same agent", async return super.resumeSession(handle, config); } })(); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); let agentId: string | undefined; try { const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -11616,6 +11842,7 @@ test("commits startup notices once on create and after restored history", async })(); const store = new RecordingTimelineStore(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, durableTimelineStore: store, logger, @@ -11678,7 +11905,7 @@ test("failed startup history closes the session without registering an agent", a return new FailingHistorySession({ provider: "codex", cwd: tmpdir() }); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); try { await expect( manager.resumeAgentFromPersistence({ @@ -11709,7 +11936,7 @@ test("usage session is a pure read of the live adapter and disappears on close", })(config); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); expect(manager.usageSession("missing")).toBeNull(); const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, { workspaceId: undefined, @@ -11736,7 +11963,12 @@ test.each(["live", "history"])( const codex = fakeCodexEmitting( path === "live" ? { turnItems: [item] } : { historyItems: [item] }, ); - const manager = new AgentManager({ clients: { codex }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex }, + registry: storage, + logger, + }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -11788,7 +12020,7 @@ test.each([false, true])( ] : [], }); - const manager = new AgentManager({ clients: { codex }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex }, logger }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -11831,7 +12063,7 @@ test("a rejected agent prompt never becomes a delivered notification", async () return new RejectingSession(config); } })(); - const manager = new AgentManager({ clients: { codex: client }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex: client }, logger }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -11868,7 +12100,7 @@ test.each([false, true])( }, ], }); - const manager = new AgentManager({ clients: { codex }, logger }); + const manager = new AgentManager({ serverId: "srv_test", clients: { codex }, logger }); const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); @@ -11892,3 +12124,52 @@ test.each([false, true])( } }, ); + +test("run journal reload binds subsequent evidence to the original submission and provider turn", async () => { + const { AgentRuns } = await import("./runs/index.js"); + const { mkdtemp, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "paseo-run-reconcile-")); + try { + const first = new AgentRuns({ serverId: "srv_original", directory }); + await first.prepare("agent-original", "submission-original"); + await first.start("agent-original", "submission-original", "provider-turn-original"); + const restarted = new AgentRuns({ serverId: "srv_original", directory }); + expect(await restarted.get("agent-original", "submission-original")).toMatchObject({ + status: "interrupted", + turnId: "provider-turn-original", + }); + await restarted.capture( + "agent-original", + "unrelated-turn", + { type: "turn_completed", provider: "codex" }, + "Unrelated output", + ); + expect((await restarted.get("agent-original", "submission-original"))?.status).toBe( + "interrupted", + ); + await restarted.capture( + "agent-original", + "provider-turn-original", + { type: "turn_completed", provider: "codex" }, + "Recovered terminal evidence", + ); + const result = await restarted.get("agent-original", "submission-original"); + expect(result).toMatchObject({ + agent: { serverId: "srv_original", agentId: "agent-original" }, + submissionId: "submission-original", + turnId: "provider-turn-original", + status: "completed", + durability: "persisted", + }); + expect(result?.checkpoints.map((checkpoint) => checkpoint.kind)).toEqual([ + "submitted", + "running", + "interrupted", + "completed", + ]); + const next = new AgentRuns({ serverId: "srv_original", directory }); + expect(await next.get("agent-original", "submission-original")).toEqual(result); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a15d94389f..2479ff6950 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,5 @@ +import { AgentRuns } from "./runs/index.js"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; @@ -7,6 +9,7 @@ import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; import { AGENT_LIFECYCLE_STATUSES, + shouldDetachFromArchivedParent, type AgentLifecycleStatus, } from "@getpaseo/protocol/agent-lifecycle"; import { @@ -314,10 +317,13 @@ export interface CreateAgentOptions { initialTitle?: string | null; // undefined is an explicit decision: the agent never appears in the sidebar. workspaceId: string | undefined; + parent?: AgentAddress | null; owner?: AgentOwner; } export interface AgentManagerOptions { + runJournalDirectory?: string; + serverId: string; pluginLifecycle?: PluginLifecycle; /** An internal workspace makes every agent created inside it internal. */ clients?: ProviderClientMap; @@ -408,6 +414,7 @@ interface ManagedAgentBase { * Null/undefined for legacy agents created before ownership stamping. */ workspaceId?: string; + parent?: AgentAddress | null; owner?: AgentOwner; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; @@ -547,6 +554,7 @@ interface WriteLabelsResult { } interface AgentMetadataPatch { + parent?: AgentAddress | null; title?: string; labels?: AgentLabelPatch; } @@ -697,17 +705,6 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str return null; } -function shouldDetachFromArchivedParent( - parent: Pick, - child: StoredAgentRecord, -): boolean { - const isCrossWorkspace = - parent.workspaceId !== undefined && - child.workspaceId !== undefined && - parent.workspaceId !== child.workspaceId; - return isCrossWorkspace || hasOpenAgentTab(child.labels); -} - function detachedAgentLabelPatch(labels: Record): AgentLabelPatch { const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null }; for (const label of Object.keys(labels)) { @@ -747,6 +744,7 @@ export class AgentManager { private readonly sessionEventTails = new Map>(); private readonly steerEventBarriers = new Map(); private readonly foregroundMutationTails = new Map>(); + private readonly runJournal: AgentRuns; private readonly runs = new AgentRunState(); private readonly subscribers = new Set(); private readonly idFactory: () => string; @@ -776,7 +774,14 @@ export class AgentManager { private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"]; private acceptingAgentRegistrations = true; + private readonly serverId: string; + constructor(options: AgentManagerOptions) { + this.serverId = options.serverId; + this.runJournal = new AgentRuns({ + serverId: options.serverId, + directory: options.runJournalDirectory, + }); this.pluginLifecycle = options.pluginLifecycle; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; @@ -861,6 +866,10 @@ export class AgentManager { this.onAgentAttention = callback; } + getAgentRun(agentId: string, submissionId: string) { + return this.runJournal.get(agentId, submissionId); + } + setAgentArchivedCallback(callback: AgentArchivedCallback): void { this.onAgentArchived = callback; } @@ -1316,6 +1325,7 @@ export class AgentManager { labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, + parent: options.parent, owner: options.owner, historyPrimed: true, }); @@ -1347,6 +1357,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + parent?: AgentAddress | null; owner?: AgentOwner; attention?: AttentionState; }, @@ -1379,6 +1390,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + parent?: AgentAddress | null; owner?: AgentOwner; attention?: AttentionState; }, @@ -1623,6 +1635,7 @@ export class AgentManager { return this.registerSession(session, storedConfig, agentId, { labels: existing.labels, workspaceId: existing.workspaceId, + parent: existing.parent, owner: existing.owner, createdAt: existing.createdAt, updatedAt: existing.updatedAt, @@ -1903,7 +1916,17 @@ export class AgentManager { ) { return; } - if (shouldDetachFromArchivedParent(parent, currentChild)) { + if ( + shouldDetachFromArchivedParent({ + parentWorkspace: parent.workspaceId + ? { serverId: this.serverId, workspaceId: parent.workspaceId } + : undefined, + childWorkspace: currentChild.workspaceId + ? { serverId: this.serverId, workspaceId: currentChild.workspaceId } + : undefined, + hasOpenTab: hasOpenAgentTab(currentChild.labels), + }) + ) { await this.detachAgentUnlocked(currentChild.id); } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); @@ -1986,6 +2009,7 @@ export class AgentManager { provider: record.provider, cwd: record.cwd, workspaceId: record.workspaceId, + parent: record.parent, owner: record.owner, session: null, capabilities: STORED_AGENT_CAPABILITIES, @@ -2154,6 +2178,7 @@ export class AgentManager { const nextRecord = { ...record, + ...(patch.parent !== undefined ? { parent: patch.parent } : {}), ...(patch.title ? { title: patch.title } : {}), ...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}), updatedAt: this.nextStoredUpdatedAt(record), @@ -2179,36 +2204,36 @@ export class AgentManager { const liveAgent = this.agents.get(agentId); if (liveAgent) { const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels); - if (!previousParentAgentId) { + const previous = { + parent: liveAgent.parent, + labels: liveAgent.labels, + updatedAt: liveAgent.updatedAt, + }; + liveAgent.parent = null; + liveAgent.labels = applyLabelPatch( + liveAgent.labels, + detachedAgentLabelPatch(liveAgent.labels), + ); + this.touchUpdatedAt(liveAgent); + try { await this.persistSnapshot(liveAgent); - const record = await registry.get(agentId); - if (!record) { - throw new Error(`Agent not found in storage after detach: ${agentId}`); - } - return { record, live: true, previousParentAgentId: null }; - } - - const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels)); - if (!record) { - throw new Error(`Agent not found in storage after detach: ${agentId}`); + } catch (error) { + Object.assign(liveAgent, previous); + throw error; } + this.emitState(liveAgent, { persist: false }); + const record = await registry.get(agentId); + if (!record) throw new Error(`Agent not found in storage after detach: ${agentId}`); return { record, live: true, previousParentAgentId }; } - const record = await registry.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); - } + if (!record) throw new Error(`Agent not found: ${agentId}`); const previousParentAgentId = getParentAgentIdFromLabels(record.labels); - if (!previousParentAgentId) { - return { record, live: false, previousParentAgentId: null }; - } - - const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels)); - if (!result.record) { - throw new Error(`Agent not found in storage after detach: ${agentId}`); - } - return { record: result.record, live: false, previousParentAgentId }; + const detached = await this.writeStoredMetadata(agentId, { + parent: null, + labels: detachedAgentLabelPatch(record.labels), + }); + return { record: detached, live: false, previousParentAgentId }; } notifyAgentState(agentId: string): void { @@ -2546,7 +2571,19 @@ export class AgentManager { }): Promise { const { agent, agentId, pendingRun, prompt, options } = params; try { + const submissionId = options?.clientMessageId ?? pendingRun.token; + await this.runJournal.prepare(agentId, submissionId); const result = await agent.session.startTurn(prompt, options); + try { + await this.runJournal.start(agentId, submissionId, result.turnId); + } catch (error) { + // The provider already admitted this turn. A journal failure cannot turn + // accepted work into a rejection or justify submitting the prompt again. + this.logger.error( + { err: error, agentId, submissionId, turnId: result.turnId }, + "Failed to persist admitted run identity", + ); + } if (pendingRun.settled) { throw new Error(`Agent ${agentId} run was canceled before its turn started`); } @@ -2564,6 +2601,12 @@ export class AgentManager { } agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; + if (!(error instanceof Error && "code" in error && error.code === "submission_exists")) + await this.runJournal + .failAdmission(agentId, options?.clientMessageId ?? pendingRun.token, errorMsg) + .catch((journalError) => { + this.logger.error({ err: journalError, agentId }, "Failed to persist rejected run"); + }); pendingRun.start = { status: "failed", error: errorMsg }; // A terminal rejection belongs after the submitted prompt even though no provider turn exists. if (options?.clientMessageId) { @@ -3574,6 +3617,7 @@ export class AgentManager { initialTitle?: string | null; publishWhenReady?: boolean; workspaceId?: string; + parent?: AgentAddress | null; owner?: AgentOwner; }, ): Promise { @@ -3752,6 +3796,7 @@ export class AgentManager { attention?: AttentionState; persistence?: AgentPersistenceHandle; workspaceId?: string; + parent?: AgentAddress | null; owner?: AgentOwner; } | undefined; @@ -3762,6 +3807,7 @@ export class AgentManager { provider: config.provider, cwd: config.cwd, workspaceId: options?.workspaceId, + parent: options?.parent, owner: options?.owner, session, capabilities: session.capabilities, @@ -4325,6 +4371,13 @@ export class AgentManager { const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true }; + const runResult = this.getRunResult( + agent.id, + eventTurnId, + event, + options?.fromHistory === true, + terminalDisposition, + ); const dispatchPromise = this.dispatchStreamEventByType({ agent, event, @@ -4339,6 +4392,13 @@ export class AgentManager { } if (!options?.fromHistory) { + if (terminalDisposition !== "stale") + await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => { + this.logger.error( + { err: error, agentId: agent.id, turnId: eventTurnId }, + "Failed to persist provider checkpoint", + ); + }); if (isTurnTerminalEvent(event)) { this.runs.settleTerminalRun(agent.id, eventTurnId); if (isForegroundEvent) { @@ -4356,6 +4416,22 @@ export class AgentManager { return flags.shouldNotifyWaiters; } + private getRunResult( + agentId: string, + turnId: string | undefined, + event: AgentStreamEvent, + fromHistory: boolean, + disposition: ActiveTurnTerminalDisposition, + ): string { + if (fromHistory || disposition === "stale" || !isTurnTerminalEvent(event)) return ""; + // Read provider output before terminal handlers append presentation-only errors. + return this.timelineStore + .getRows(agentId) + .filter((row) => row.turnId === turnId) + .flatMap((row) => (row.item.type === "assistant_message" ? [row.item.text] : [])) + .join(""); + } + private traceHandleStreamEventStart( agent: ActiveManagedAgent, event: AgentStreamEvent, @@ -5104,6 +5180,7 @@ export class AgentManager { private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise { this.agentStreamCoalescer.flushAll(); + await this.runJournal.flush(); // Drain tasks, including tasks spawned while awaiting. while ( this.backgroundTasks.size > 0 || diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3f2a9b7775..5ee5c290b6 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -83,6 +83,7 @@ export function toStoredAgentRecord( lastActivityAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, title: options?.title ?? null, + parent: agent.parent, labels: agent.labels, lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, @@ -138,6 +139,7 @@ export function toAgentPayload( pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, + parent: agent.parent, labels: agent.labels, ...(agent.internal ? { internal: true } : {}), }; @@ -249,6 +251,7 @@ export function buildStoredAgentPayload( attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, ...(record.internal ? { internal: true } : {}), + parent: record.parent, labels: normalizeLabels(record.labels), ...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}), ...(providerAvailable ? {} : { providerUnavailable: true }), @@ -273,6 +276,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp ?? null, + parent: agent.parent, labels: agent.labels, ...(agent.providerUnavailable ? { providerUnavailable: true } : {}), }; diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index dcdfdb1cbf..95838e7157 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -91,7 +91,11 @@ function createFinishNotificationScenario( Reflect.set(callerAgent, "lifecycle", "idle"); Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); + const agentManager = new AgentManager({ + serverId: "srv_test", + clients: {}, + logger: createTestLogger(), + }); Reflect.set(agentManager, "getAgent", (agentId: string) => { if (agentId === "child-agent") { return childAgent; @@ -490,7 +494,11 @@ it("does not notify archived callers", async () => { const streamAgentSpy = vi.fn(() => (async function* noop() {})()); const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})()); - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); + const agentManager = new AgentManager({ + serverId: "srv_test", + clients: {}, + logger: createTestLogger(), + }); Reflect.set( agentManager, "getAgent", @@ -701,6 +709,7 @@ async function createRunStartScenario(startDelayMs: number | null): Promise<{ const workdir = mkdtempSync(join(tmpdir(), "agent-run-start-budget-")); const client = new SlowStartAgentClient(startDelayMs); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { codex: client }, logger: createTestLogger(), }); diff --git a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts index a92e547edc..8315c107ee 100644 --- a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts @@ -38,6 +38,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { beforeEach(async () => { cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-")); manager = new AgentManager({ + serverId: "srv_test", clients: createRealProviderClients(["codex", "claude"], logger), logger, }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index be94a31d49..e5d746ffc3 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -1,3 +1,4 @@ +import { AgentAddressSchema } from "@getpaseo/protocol/addresses"; import { promises as fs, type Dirent } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -43,6 +44,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z .optional(); const STORED_AGENT_SCHEMA = z.object({ + parent: AgentAddressSchema.nullable().optional(), id: z.string(), provider: z.string(), cwd: z.string(), diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index f12309da2a..e1c77bca91 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -17,6 +17,7 @@ const logger = createTestLogger(); function createRealAgentManager(storage: AgentStorage): AgentManager { return new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 05f98285e1..d435752925 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; @@ -56,6 +57,7 @@ export type EnsureWorkspaceForCreate = ( ) => Promise; export interface CreateAgentFromSessionInput { + parent?: AgentAddress | null; kind: "session"; onAgentReady?: (agent: ManagedAgent) => Promise; agentId?: string; @@ -289,6 +291,7 @@ async function resolveSessionCreateAgent( return { config: sessionConfig, createOptions: { + parent: input.parent, labels: input.labels, initialPrompt: trimmedPrompt, env: input.env, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 6e093a18a4..febd246562 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1598,6 +1598,7 @@ describe("create_agent MCP tool", () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const agentManager = new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger, @@ -3547,6 +3548,7 @@ describe("create_agent MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true }); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -3761,6 +3763,7 @@ describe("create_agent MCP tool", () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-workspace-inherit-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const agentManager = new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger, @@ -4277,7 +4280,12 @@ describe("send_agent_prompt MCP tool", () => { const client = new HeldTurnAgentClient("codex", true, { steerResult: available ? "accepted" : "unavailable", }); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); try { const parent = await manager.createAgent( { provider: "codex", cwd: existingCwd }, @@ -4336,7 +4344,12 @@ describe("send_agent_prompt MCP tool", () => { finishDuringSteer: true, rejectSteer: rejected, }); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); let mcpClient: Client | undefined; try { const parent = await manager.createAgent( @@ -4411,7 +4424,12 @@ describe("send_agent_prompt MCP tool", () => { finishDuringSteer, rejectSteer: true, }); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); let mcpClient: Client | undefined; try { const parent = await manager.createAgent( @@ -4483,7 +4501,12 @@ describe("send_agent_prompt MCP tool", () => { finishDuringSteer: false, rejectSteer: true, }); - const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + registry: storage, + logger, + }); let mcpClient: Client | undefined; try { const parent = await manager.createAgent( @@ -4700,6 +4723,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -4779,6 +4803,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -4856,6 +4881,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -4912,6 +4938,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -4978,6 +5005,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -5039,6 +5067,7 @@ describe("send_agent_prompt MCP tool", () => { const parentClient = new HeldTurnAgentClient("claude", false); const childClient = new HeldTurnAgentClient("codex", true); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, codex: childClient }, registry: storage, logger, @@ -5113,6 +5142,7 @@ describe("send_agent_prompt MCP tool to an OMP child", () => { const parentClient = new HeldTurnAgentClient("claude", false); const omp = new FakeOmp(); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { claude: parentClient, omp: new OmpAgentClient({ diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index aa0aa1a924..25802fe678 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -299,6 +299,7 @@ describe("PluginAgentClientRegistry", () => { }, }); const manager = new AgentManager({ + serverId: "srv_test", logger, clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, diff --git a/packages/server/src/server/agent/provider-options.test.ts b/packages/server/src/server/agent/provider-options.test.ts index f5b97d064a..a7a1acfc8b 100644 --- a/packages/server/src/server/agent/provider-options.test.ts +++ b/packages/server/src/server/agent/provider-options.test.ts @@ -255,6 +255,7 @@ describe("configured provider options", () => { }, }); const manager = new AgentManager({ + serverId: "srv_test", logger, clients: { claude: registry.claude.createClient(logger) }, providerDefinitions: { claude: registry.claude }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 0469d9fbe1..bcc8534e05 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -226,6 +226,7 @@ describe("Codex app-server provider (local e2e)", () => { const logger = createTestLogger(); const storage = new AgentStorage(path.join(cwd, "agents"), logger); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CodexAppServerAgentClient(logger) }, registry: storage, logger, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 9cfeca60d3..444e7c7796 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -141,6 +141,7 @@ test("unexpected Codex app-server exit fails the active run and agent", async () const workdir = mkdtempSync(join(tmpdir(), "codex-process-exit-")); const appServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([appServer]) }, logger, }); @@ -201,6 +202,7 @@ test("replacement self-heals when Codex reports that the tracked turn is already }), }); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([appServer]) }, logger, }); @@ -253,6 +255,7 @@ test("intentional agent close stays clean while a Codex turn is active", async ( const workdir = mkdtempSync(join(tmpdir(), "codex-process-close-")); const appServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([appServer]) }, logger, }); @@ -302,6 +305,7 @@ test("idle Codex app-server exit reconnects without publishing a failed turn", a const exitedAppServer = createFakeCodexAppServer(); const replacementAppServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([exitedAppServer, replacementAppServer]), }, @@ -355,6 +359,7 @@ test("failed reconnect preserves manager events for a later successful run", asy }); const successfulReconnect = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([exitedAppServer, failedReconnect, successfulReconnect]), }, @@ -395,6 +400,7 @@ test("unexpected exit fails an autonomous Codex turn", async () => { const workdir = mkdtempSync(join(tmpdir(), "codex-process-autonomous-exit-")); const appServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([appServer]) }, logger, }); @@ -436,6 +442,7 @@ test("provider permissions do not survive an unexpected exit and reconnect", asy const exitedAppServer = createFakeCodexAppServer(); const replacementAppServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([exitedAppServer, replacementAppServer]), }, @@ -512,6 +519,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect", const exitedAppServer = createFakeCodexAppServer(); const replacementAppServer = createFakeCodexAppServer(); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new ProcessExitCodexClient([exitedAppServer, replacementAppServer]), }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8fca5f24c0..a604f0c343 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -7149,7 +7149,11 @@ describe("Codex interactive resume startup", () => { return session; }, }; - const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() }); + const manager = new AgentManager({ + serverId: "srv_test", + clients: { codex: client }, + logger: createTestLogger(), + }); let id: string | undefined; try { const resumed = await manager.resumeAgentFromPersistence( diff --git a/packages/server/src/server/agent/providers/codex-async-questions.test.ts b/packages/server/src/server/agent/providers/codex-async-questions.test.ts index 8915688bde..9d7f72761b 100644 --- a/packages/server/src/server/agent/providers/codex-async-questions.test.ts +++ b/packages/server/src/server/agent/providers/codex-async-questions.test.ts @@ -186,6 +186,7 @@ test("failed rewind preserves question state", async () => { async function manage(session: CodexAppServerAgentSession) { const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: { provider: "codex", diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index c9c0f07161..a82a4dbd78 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -500,6 +500,7 @@ describe("MockLoadTestAgentClient", () => { try { const client = new MockLoadTestAgentClient(); const manager = new AgentManager({ + serverId: "srv_test", clients: { mock: client }, idFactory: () => "00000000-0000-4000-8000-000000000001", logger: createTestLogger(), diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index 843ef21ebb..abc8a0299a 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -33,7 +33,7 @@ test("real OpenCode server advertises agent-scoped notification descriptions", a const logger = createTestLogger(); const providerSnapshotManager = new ProviderSnapshotManager({ logger }); const dependencies = { - agentManager: new AgentManager({ logger }), + agentManager: new AgentManager({ serverId: "srv_test", logger }), agentStorage: new AgentStorage(path.join(root, "agents"), logger), providerSnapshotManager, logger, diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index f9a776d168..8b49d901c8 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -22,7 +22,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-7045-")); const logger = createTestLogger(); const providerSnapshotManager = new ProviderSnapshotManager({ logger }); - const agentManager = new AgentManager({ logger }); + const agentManager = new AgentManager({ serverId: "srv_test", logger }); const agentStorage = new AgentStorage(paseoHome, logger); const unused = async (): Promise => { throw new Error("Unexpected workspace or agent creation"); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 8fc6c23d93..cdc91d1912 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -81,7 +81,12 @@ describe("OpenCodeBridge", () => { const logger = createTestLogger(); const clients = createTestAgentClients(); const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); - const agentManager = new AgentManager({ clients, registry: agentStorage, logger }); + const agentManager = new AgentManager({ + serverId: "srv_test", + clients, + registry: agentStorage, + logger, + }); const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients }); const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger }; const bridge = new OpenCodeBridge({ paseoHome, logger }); @@ -466,7 +471,7 @@ describe("OpenCodeBridge", () => { const logger = createTestLogger(); const providerSnapshotManager = new ProviderSnapshotManager({ logger }); const catalog = createPaseoToolCatalog({ - agentManager: new AgentManager({ logger }), + agentManager: new AgentManager({ serverId: "srv_test", logger }), agentStorage: new AgentStorage(paseoHome, logger), providerSnapshotManager, logger, diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index baf0cf7f9b..95f5d5a92d 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -122,6 +122,7 @@ describe("default provider availability", () => { const workdir = makeTempDir("provider-availability-manager-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CodexAppServerAgentClient(createTestLogger()), }, @@ -144,6 +145,7 @@ describe("default provider availability", () => { const workdir = makeTempDir("provider-availability-resume-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CodexAppServerAgentClient(createTestLogger()), }, diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index df5c242251..0b41edc850 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -52,6 +52,7 @@ class RewindHistoryGate { async function createRewindHarness(options: { historyGate?: RewindHistoryGate } = {}) { const session = new FakeRewindSession(options.historyGate?.wait.bind(options.historyGate)); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: new FakeRewindClient(session) }, logger: createTestLogger(), idFactory: () => "00000000-0000-4000-8000-000000000901", @@ -142,6 +143,7 @@ describe("AgentManager rewind", () => { const session = new RejectingInterruptSession(); const manager = new AgentManager({ + serverId: "srv_test", clients: { claude: new FakeRewindClient(session) }, logger: createTestLogger(), idFactory: () => "00000000-0000-4000-8000-000000000902", diff --git a/packages/server/src/server/agent/runs/index.ts b/packages/server/src/server/agent/runs/index.ts new file mode 100644 index 0000000000..6a9c07c050 --- /dev/null +++ b/packages/server/src/server/agent/runs/index.ts @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + AgentRunRecordSchema, + type AgentRunRecord, + type AgentRunCheckpoint, +} from "@getpaseo/protocol/agent-runs"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { writeJsonFileAtomic } from "../../atomic-file.js"; + +/** Durable submission/turn identity and checkpoints; idle agent state is never a run result. */ +export class AgentRuns { + private readonly records = new Map(); + private readonly submissionsByTurn = new Map(); + private readonly loadedAgents = new Set(); + private queue: Promise = Promise.resolve(); + constructor(private readonly options: { serverId: string; directory?: string }) {} + + private identity(agentId: string, submissionId: string): string { + return JSON.stringify([agentId, submissionId]); + } + private async load(agentId: string): Promise { + if (this.loadedAgents.has(agentId)) return; + let records: AgentRunRecord[] = []; + if (this.options.directory) { + try { + const saved: unknown = JSON.parse(await readFile(this.path(agentId), "utf8")); + records = AgentRunRecordSchema.array().parse(saved); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + for (const record of records) { + if (record.agent.serverId !== this.options.serverId || record.agent.agentId !== agentId) + throw new Error("Run journal identity does not match its owning agent"); + if (!terminal(record) && record.status !== "interrupted") + this.append(record, { + kind: "interrupted", + error: "Destination restarted before terminal provider evidence was recorded", + }); + this.records.set(this.identity(agentId, record.submissionId), record); + if (record.turnId && !terminal(record)) + this.submissionsByTurn.set(this.identity(agentId, record.turnId), record.submissionId); + } + if (records.length && this.options.directory) + await writeJsonFileAtomic(this.path(agentId), records); + this.loadedAgents.add(agentId); + } + private async read(agentId: string, submissionId: string): Promise { + await this.load(agentId); + const record = this.records.get(this.identity(agentId, submissionId)); + return record ? structuredClone(record) : null; + } + private path(agentId: string): string { + if (!this.options.directory) throw new Error("Run journal has no persistence directory"); + return join( + this.options.directory, + `${createHash("sha256").update(agentId).digest("hex")}.json`, + ); + } + private async persistAgent(agentId: string): Promise { + const records = [...this.records.values()] + .filter((record) => record.agent.agentId === agentId) + .map((record) => Object.assign({}, record, { durability: "persisted" as const })); + if (this.options.directory) await writeJsonFileAtomic(this.path(agentId), records); + for (const record of records) + this.records.set(this.identity(agentId, record.submissionId), record); + } + private async write(record: AgentRunRecord, retainEvidence = false): Promise { + const key = this.identity(record.agent.agentId, record.submissionId); + const previous = this.records.get(key); + this.records.set(key, { ...record, durability: "pending" }); + try { + await this.persistAgent(record.agent.agentId); + } catch (error) { + if (!retainEvidence) { + if (previous) this.records.set(key, previous); + else this.records.delete(key); + } + throw error; + } + } + private mutate(operation: () => Promise): Promise { + const result = this.queue.then(operation); + this.queue = result.catch(() => {}); + return result; + } + async get(agentId: string, submissionId: string): Promise { + return this.mutate(async () => { + await this.load(agentId); + if ( + [...this.records.values()].some( + (record) => record.agent.agentId === agentId && record.durability === "pending", + ) + ) { + // Keep observed evidence readable while storage is unavailable. A later + // read or checkpoint retries the same atomic collection, without replay. + await this.persistAgent(agentId).catch(() => {}); + } + return this.read(agentId, submissionId); + }); + } + prepare(agentId: string, submissionId: string): Promise { + return this.mutate(async () => { + if (await this.read(agentId, submissionId)) + throw Object.assign( + new Error( + "This run submission already exists; inspect it before submitting different work", + ), + { code: "submission_exists" }, + ); + await this.write({ + agent: { serverId: this.options.serverId, agentId }, + submissionId, + turnId: null, + status: "submitted", + checkpoints: [{ sequence: 1, kind: "submitted", at: new Date().toISOString() }], + }); + }); + } + start(agentId: string, submissionId: string, turnId: string): Promise { + return this.mutate(async () => { + const record = await this.read(agentId, submissionId); + if (!record) throw new Error("Run was not prepared before provider admission"); + if (record.turnId !== null && record.turnId !== turnId) + throw new Error("Run submission cannot bind to a different provider turn"); + record.turnId = turnId; + this.append(record, { kind: "running" }); + // Admission has happened. Retain its binding even if durable storage fails; + // subsequent provider evidence can retry persistence without replaying work. + this.records.set(this.identity(agentId, submissionId), record); + this.submissionsByTurn.set(this.identity(agentId, turnId), submissionId); + await this.write(record, true); + }); + } + capture( + agentId: string, + turnId: string | undefined, + event: AgentStreamEvent, + result: string, + ): Promise { + if (!turnId) return Promise.resolve(); + const checkpoint = checkpointFor(event, result); + if (!checkpoint) return Promise.resolve(); + return this.mutate(async () => { + await this.load(agentId); + const submissionId = this.submissionsByTurn.get(this.identity(agentId, turnId)); + if (!submissionId) return; + const record = await this.read(agentId, submissionId); + if (!record || terminal(record)) return; + if ( + checkpoint.permissionRequestId && + record.checkpoints.some( + (existing) => + existing.kind === checkpoint.kind && + existing.permissionRequestId === checkpoint.permissionRequestId, + ) + ) + return; + this.append(record, checkpoint); + if (terminal(record)) this.submissionsByTurn.delete(this.identity(agentId, turnId)); + await this.write(record, true); + }); + } + failAdmission(agentId: string, submissionId: string, error: string): Promise { + return this.mutate(async () => { + const record = await this.read(agentId, submissionId); + if (!record || terminal(record)) return; + this.append(record, { kind: "failed", error }); + await this.write(record, true); + }); + } + private append(record: AgentRunRecord, input: Omit): void { + record.checkpoints.push({ + ...input, + sequence: record.checkpoints.length + 1, + at: new Date().toISOString(), + }); + record.status = input.kind === "resumed" ? "running" : input.kind; + } + async flush(): Promise { + await this.queue; + } +} +function terminal(record: AgentRunRecord): boolean { + return ( + record.status === "completed" || record.status === "failed" || record.status === "canceled" + ); +} +function checkpointFor( + event: AgentStreamEvent, + result: string, +): Omit | null { + switch (event.type) { + case "permission_requested": + return { kind: "permission", permissionRequestId: event.request.id }; + case "permission_resolved": + return { kind: "resumed", permissionRequestId: event.requestId }; + case "turn_completed": + return { kind: "completed", result }; + case "turn_failed": + return { kind: "failed", error: event.error, result }; + case "turn_canceled": + return { kind: "canceled", error: event.reason, result }; + default: + return null; + } +} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 80599a74a3..4cfcad4473 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -6,6 +6,12 @@ type OutboundOperation = SessionOutboundMessage["type"]; export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null; const INBOUND_PERMISSION = { + "agent.create.cancel.request": "workspace.write", + "agent.runs.get.request": "workspace.read", + "agent.subagents.detach.request": "workspace.write", + "agent.subagents.forget.request": "workspace.write", + "agent.subagents.create.request": "workspace.write", + "agent.subagents.list.request": "workspace.read", "hosts.list.request": "daemon.read", "hosts.add.request": "tunnel.manage", "hosts.update.request": "tunnel.manage", @@ -224,6 +230,13 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "agent.create.cancel.response": "workspace.write", + "agent.runs.get.response": "workspace.read", + "agent.subagents.detach.response": "workspace.write", + "agent.subagents.forget.response": "workspace.write", + "agent.subagents.create.response": "workspace.write", + "agent.subagents.list.response": "workspace.read", + "agent.subagents.changed": "workspace.read", "hosts.list.response": "daemon.read", "hosts.changed": "daemon.read", "hosts.add.response": "tunnel.manage", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ee117615c8..2f4e59fc30 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -952,6 +952,8 @@ export async function createPaseoDaemon( }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ + runJournalDirectory: path.join(config.paseoHome, "agent-runs"), + serverId, pluginLifecycle: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 77813bc01d..c567db1930 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -910,6 +910,12 @@ async function hostWire(b: TestPaseoDaemon) { const http = createServer(); const wss = new WebSocketServer({ server: http }); const frames: { binary: boolean; bytes: Buffer; direction: "toB" | "fromB" }[] = []; + const responses: SessionOutboundMessage[] = []; + const holds = new Set<{ + matches(message: SessionOutboundMessage): boolean; + send: (() => void)[]; + captured(): void; + }>(); let connections = 0; wss.on("connection", (downstream) => { connections++; @@ -930,7 +936,30 @@ async function hostWire(b: TestPaseoDaemon) { upstream.on("message", (data, binary) => { const bytes = Buffer.from(data as Buffer); frames.push({ binary, bytes, direction: "fromB" }); - if (downstream.readyState === 1) downstream.send(bytes, { binary }); + const send = () => { + if (downstream.readyState === 1) downstream.send(bytes, { binary }); + }; + const payload = binary ? decodeHostRouteFrame(bytes)?.payload : bytes; + if (payload) { + try { + let envelope = JSON.parse(Buffer.from(payload).toString()); + while (envelope.type === "session" && envelope.message?.type === "hosts.route.frame") + envelope = JSON.parse(envelope.message.payload.data); + if (envelope.type === "session") { + const validated = WSOutboundMessageSchema.parse(envelope); + if (validated.type === "session") responses.push(validated.message); + const hold = [...holds].find((candidate) => candidate.matches(envelope.message)); + if (hold) { + hold.send.push(send); + hold.captured(); + return; + } + } + } catch { + /* Non-JSON binary frames remain ordinary transport traffic. */ + } + } + send(); }); upstream.on("error", () => downstream.close()); upstream.on("close", () => downstream.close()); @@ -946,10 +975,26 @@ async function hostWire(b: TestPaseoDaemon) { const address = http.address(); if (!address || typeof address === "string") throw new Error("Missing wire port"); return { + responses, connection: `127.0.0.1:${address.port}`, frames, connections: () => connections, active: () => wss.clients.size, + holdResponses(matches: (message: SessionOutboundMessage) => boolean) { + let captured!: () => void; + const ready = new Promise((resolve) => { + captured = resolve; + }); + const hold = { matches, send: [] as (() => void)[], captured }; + holds.add(hold); + return { + ready, + release() { + holds.delete(hold); + for (const send of hold.send.splice(0)) send(); + }, + }; + }, retarget(next: TestPaseoDaemon) { destination = next; for (const socket of wss.clients) socket.close(); @@ -1129,7 +1174,9 @@ test("channel output accounts for escaped text, awaits physical drain, and prese expect(wss.clients.size).toBe(1); }); -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; test.skipIf(process.platform !== "linux" || !existsSync("/usr/sbin/sshd"))( "real SSH shares one tunnel across scopes and owns fresh attempts after recovery", async () => { @@ -1530,7 +1577,10 @@ test("disposing a daemon-owned runtime closes its source session and failed admi const active = await sessions(); expect(active[0]).toBe(before[0]! + 1); expect(active[1]).toBe(before[1]! + 1); - await runtime.dispose(); + const disposing = runtime.dispose(); + expect(runtime.dispose()).toBe(disposing); + await expect(runtime.client(a.daemon.getServerId())).rejects.toThrow("disposed"); + await disposing; await expect.poll(sessions).toEqual(before); await expect(runtime.directory()).rejects.toThrow("disposed"); const revoked = Object.assign(new Error("Runtime authority was revoked"), { @@ -1546,3 +1596,932 @@ test("disposing a daemon-owned runtime closes its source session and failed admi ).rejects.toMatchObject({ code: "access_denied" }); await expect.poll(sessions).toEqual(before); }); + +test("SDK creation persists a qualified parent without resolving a colliding agent on the child host", async () => { + const a = await daemon(); + const root = await mkdtemp(join(tmpdir(), "paseo-parent-address-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const b = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => b.close()); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const id = crypto.randomUUID(); + const parent = await origin.paseo.agents.create({ + agentId: id, + cwd: a.staticDir, + config: { provider: "codex/test" }, + title: "Parent on A", + }); + await destination.paseo.agents.create({ + agentId: id, + cwd: b.staticDir, + config: { provider: "codex/test" }, + title: "Unrelated on B", + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const child = await workspace.agents.create({ + config: { provider: "codex/test" }, + parent, + title: "Child on B", + }); + expect(child.current()?.parent).toEqual(parent.address); + expect((await b.daemon.agentStorage.get(child.id))?.parent).toEqual(parent.address); + await b.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(child.id); + expect((await restored.refresh())?.agent.parent).toEqual(parent.address); + expect((await restarted.daemon.agentStorage.get(child.id))?.parent).toEqual(parent.address); +}); + +test("parent capability owns local and fleet membership, idempotent creation and restart recovery", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-subagent-owner-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const local = await parent.subagents.create({ + config: { provider: "codex/test" }, + idempotencyKey: "local-child", + }); + const input = { + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "fleet-child", + }; + const [first, second] = await Promise.all([ + parent.subagents.create(input), + parent.subagents.create(input), + ]); + expect(first.address).toEqual(second.address); + expect(local.address.serverId).toBe(parent.address.serverId); + expect(first.address.serverId).toBe(b.daemon.getServerId()); + expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual( + expect.arrayContaining([local.address, first.address]), + ); + const viewer = createHostClient(`127.0.0.1:${a.port}`, { + clientId: crypto.randomUUID(), + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + cleanup.push(async () => { + await viewer.close(); + }); + await viewer.connect(); + const viewerApi = createPaseoApi(viewer); + cleanup.push(() => viewerApi.dispose()); + await expect( + viewerApi.agents.ref(parent.id).subagents.create({ ...input, idempotencyKey: "unauthorized" }), + ).rejects.toMatchObject({ code: "access_denied" }); + expect( + (await destination.paseo.agents.list({ includeBackground: true })).entries.filter( + (entry) => entry.agent.parent?.agentId === parent.id, + ), + ).toHaveLength(1); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restoredParent = next.paseo.agents.ref(parent.id); + expect((await restoredParent.subagents.list()).rows.map((row) => row.child)).toEqual( + expect.arrayContaining([local.address, first.address]), + ); + await online(next.paseo); + const recovered = await restoredParent.subagents.create(input); + expect(recovered.address).toEqual(first.address); +}); + +test("retained derived sessions cannot supply durable principal authority", async () => { + const a = await daemon(); + const owner = await api(a); + const parent = await owner.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const permissions = ["daemon.read", "workspace.read", "workspace.write"] as const; + let available = true; + const runtime = await a.daemon.createHostRuntime({ + permissionCeiling: permissions, + resolveAdmission: async () => { + if (!available) + throw Object.assign(new Error("Origin authority removed"), { code: "access_denied" }); + return { principalId: "external-principal", permissions }; + }, + }); + cleanup.push(() => runtime.dispose()); + const derived = await runtime.client(parent.serverId); + cleanup.push(() => derived.dispose()); + const heldParent = derived.agents.ref(parent.id); + await expect( + heldParent.subagents.create({ config: { provider: "codex/test" } }), + ).rejects.toMatchObject({ code: "unsupported_capability" }); + available = false; + // The derived socket is still alive and readable; it must not become an authority owner. + expect((await heldParent.refresh())?.agent.id).toBe(parent.id); + await expect( + heldParent.subagents.create({ config: { provider: "codex/test" } }), + ).rejects.toMatchObject({ code: "unsupported_capability" }); + await expect(heldParent.subagents.list()).rejects.toMatchObject({ + code: "unsupported_capability", + }); + const observation = heldParent.subagents.observe(); + await expect(observation.ready).rejects.toMatchObject({ code: "unsupported_capability" }); + await observation.release(); + expect((await parent.subagents.list()).rows).toEqual([]); +}); + +test("qualified parent archive preserves workspace and open-tab policy", async () => { + const a = await daemon(); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspaceId = parent.current()!.workspaceId!; + const other = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + await destination.paseo.workspaces.create({ + workspaceId, + source: { kind: "directory", path: b.staticDir }, + }); + const same = await parent.subagents.create({ config: { provider: "codex/test" } }); + const separate = await parent.subagents.create({ + workspaceId: other.id, + config: { provider: "codex/test" }, + }); + const open = await parent.subagents.create({ + config: { provider: "codex/test" }, + labels: { "paseo.open-agent-tab.test": "true" }, + }); + const fleet = await parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId, + config: { provider: "codex/test" }, + }); + await parent.archive(); + expect((await same.refresh())?.agent.archivedAt).toEqual(expect.any(String)); + for (const child of [separate, open, fleet]) { + const snapshot = (await child.refresh())!.agent; + expect(snapshot.archivedAt).toBeNull(); + expect(snapshot.parent).toBeNull(); + } + const rows = (await parent.subagents.list()).rows; + expect(rows.find((row) => row.child.agentId === same.id)?.phase).toBe("archived"); + expect(rows.filter((row) => row.phase === "detached")).toHaveLength(3); +}); + +test("parent archive retains a truthful detach obligation across unavailable destination and origin restart", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-pending-detach-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const child = await parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + }); + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await parent.archive(); + const pending = (await parent.subagents.list()).rows[0]!; + expect(pending).toMatchObject({ + child: child.address, + phase: "detach_pending", + freshness: "unavailable", + }); + const directChild = destination.paseo.agents.ref(child.id); + expect((await directChild.refresh())?.agent).toMatchObject({ + parent: parent.address, + archivedAt: null, + }); + await expect(origin.paseo.hosts.remove(saved.registrationId)).rejects.toMatchObject({ + code: "relationships_pending", + }); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(parent.id); + expect((await restored.subagents.list()).rows[0]).toMatchObject({ + child: child.address, + phase: "detach_pending", + }); + expect((await directChild.refresh())?.agent.parent).toEqual(parent.address); + await next.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(next.paseo); + await expect.poll(async () => (await restored.subagents.list()).rows[0]?.phase).toBe("detached"); + expect((await directChild.refresh())?.agent).toMatchObject({ parent: null, archivedAt: null }); + expect( + (await destination.paseo.agents.list({ includeBackground: true })).entries.filter( + (entry) => entry.agent.id === child.id, + ), + ).toHaveLength(1); +}); + +test("explicit detach stays pending offline and forgetting releases only origin membership", async () => { + const a = await daemon(); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const backendRuntimeCount = async () => + Number(/Host runtimes: (\d+)/.exec((await origin.client.collectDiagnostics()).diagnostic)![1]); + const beforeRuntimeCount = await backendRuntimeCount(); + const input = { + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "kept-child", + }; + const child = await parent.subagents.create(input); + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + expect(await parent.subagents.detach(child.address)).toMatchObject({ + child: child.address, + phase: "detach_pending", + freshness: "unavailable", + }); + const directChild = destination.paseo.agents.ref(child.id); + expect((await directChild.refresh())?.agent).toMatchObject({ + parent: parent.address, + archivedAt: null, + }); + await parent.subagents.forget(child.address); + expect((await parent.subagents.list()).rows).toEqual([]); + await expect.poll(backendRuntimeCount).toBe(beforeRuntimeCount); + await origin.paseo.hosts.remove(saved.registrationId); + expect((await directChild.refresh())?.agent).toMatchObject({ + parent: parent.address, + archivedAt: null, + }); + await expect(parent.subagents.create(input)).rejects.toMatchObject({ + code: "relationship_forgotten", + }); +}); + +test.each(["forget", "archive"] as const)( + "a delayed creation response cannot overwrite newer %s intent", + async (intent) => { + const root = await mkdtemp(join(tmpdir(), "paseo-delayed-intent-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const b = await daemon(); + const wire = await hostWire(b); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: wire.connection }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const heldCreation = wire.holdResponses((message) => message.type === "agent.create.response"); + const creating = parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "delayed", + }); + void creating.catch(() => {}); + await Promise.race([ + heldCreation.ready, + creating.then(() => { + throw new Error("Creation resolved before its response was held"); + }), + ]); + const row = (await parent.subagents.list()).rows[0]!; + expect(row.phase).toBe("creating"); + if (intent === "forget") await parent.subagents.forget(row.child); + else await parent.archive(); + const responsesBeforeCompletion = wire.responses.length; + const heldRefresh = wire.holdResponses((message) => message.type === "fetch_agent_response"); + heldCreation.release(); + // The completion can start ordinary reads, but must preserve the already committed lifecycle decision. + await heldRefresh.ready; + const directory = await parent.subagents.list(); + heldRefresh.release(); + const created = await creating; + if (intent === "forget") expect(directory.rows).toEqual([]); + else expect(directory.rows[0]?.phase).toBe("detached"); + expect(created.address).toEqual(row.child); + expect( + wire.responses + .slice(responsesBeforeCompletion) + .filter( + (message) => message.type === "fetch_agents_response" && message.payload.subscriptionId, + ), + ).toEqual([]); + if (intent === "forget") { + await parent.archive(); + expect( + (await destination.paseo.agents.ref(row.child.agentId).refresh())?.agent.archivedAt, + ).toBeNull(); + } + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(parent.id); + if (intent === "forget") { + expect((await restored.subagents.list()).rows).toEqual([]); + await restored.archive(); + expect( + (await destination.paseo.agents.ref(row.child.agentId).refresh())?.agent.archivedAt, + ).toBeNull(); + } else expect((await restored.subagents.list()).rows[0]?.phase).toBe("detached"); + }, +); + +test("forget remains authoritative while a child snapshot observation is being established", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-refresh-intent-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const b = await daemon(); + const wire = await hostWire(b); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: wire.connection }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const held = wire.holdResponses((message) => message.type === "fetch_agents_response"); + const child = await parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + }); + const directChild = destination.paseo.agents.ref(child.id); + await directChild.detach(); + + await held.ready; + const forgetting = parent.subagents.forget(child.address); + await expect.poll(async () => (await parent.subagents.list()).rows.length).toBe(0); + held.release(); + await forgetting; + expect((await parent.subagents.list()).rows).toEqual([]); + await parent.archive(); + expect((await directChild.refresh())?.agent.archivedAt).toBeNull(); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + expect((await next.paseo.agents.ref(parent.id).subagents.list()).rows).toEqual([]); +}); + +test("run checkpoints retain submission identity, permission/resume and terminal results across destination restart", async () => { + const a = await daemon(); + const root = await mkdtemp(join(tmpdir(), "paseo-run-checkpoint-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const b = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => b.close()); + const origin = await api(a); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const runtime = origin.paseo.hosts.runtime(); + cleanup.push(() => runtime.dispose()); + const target = await runtime.client(b.daemon.getServerId()); + cleanup.push(() => target.dispose()); + const child = await target.agents.create({ + cwd: b.staticDir, + config: { provider: "codex/test", modeId: "default" }, + }); + expect(await child.runs.get("never-submitted")).toBeNull(); + await child.send("Reply with CHECKPOINT_FIRST", { messageId: "first-submission" }); + await expect + .poll(async () => (await child.runs.get("first-submission"))?.status) + .toBe("completed"); + const first = (await child.runs.get("first-submission"))!; + expect(first).toMatchObject({ + agent: child.address, + submissionId: "first-submission", + turnId: expect.any(String), + status: "completed", + }); + expect(first.checkpoints.at(-1)).toMatchObject({ kind: "completed", result: expect.any(String) }); + await child.send('printf "ok" > permission.txt', { messageId: "permission-submission" }); + await expect + .poll(async () => (await child.runs.get("permission-submission"))?.status) + .toBe("permission"); + const permission = (await child.refresh())!.agent.pendingPermissions[0]!; + expect((await child.runs.get("permission-submission"))?.checkpoints.at(-1)).toMatchObject({ + kind: "permission", + permissionRequestId: permission.id, + }); + await child.respondToPermission({ requestId: permission.id, response: { behavior: "allow" } }); + await expect + .poll(async () => (await child.runs.get("permission-submission"))?.status) + .toBe("completed"); + const second = (await child.runs.get("permission-submission"))!; + expect(second.turnId).not.toBe(first.turnId); + expect(second.checkpoints.map((checkpoint) => checkpoint.kind)).toEqual( + expect.arrayContaining(["submitted", "running", "permission", "resumed", "completed"]), + ); + expect(await child.runs.get("first-submission")).toEqual(first); + await b.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(child.id); + expect(await restored.runs.get("first-submission")).toEqual(first); + expect(await restored.runs.get("permission-submission")).toEqual(second); + expect(await restored.runs.get("never-submitted")).toBeNull(); +}); + +test.each(["detach", "archive"] as const)( + "ordinary child %s confirms terminal membership and releases the relationship observation", + async (action) => { + const a = await daemon(); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const count = async () => + Number( + /Host runtimes: (\d+)/.exec((await origin.client.collectDiagnostics()).diagnostic)![1], + ); + const before = await count(); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const child = await parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + }); + await destination.paseo.agents.ref(child.id)[action](); + await expect + .poll(async () => (await parent.subagents.list()).rows[0]?.phase) + .toBe(action === "detach" ? "detached" : "archived"); + await expect.poll(count).toBe(before); + }, +); + +test("run result never inherits an earlier turn answer when the next turn fails without output", async () => { + const instance = await daemon(); + const { paseo } = await api(instance); + const agent = await paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + await agent.send("Reply with FIRST_ANSWER", { messageId: "answered" }); + await expect.poll(async () => (await agent.runs.get("answered"))?.status).toBe("completed"); + expect((await agent.runs.get("answered"))?.checkpoints.at(-1)?.result).not.toBe(""); + await agent.send("Emit a turn failure", { messageId: "empty-failure" }); + await expect.poll(async () => (await agent.runs.get("empty-failure"))?.status).toBe("failed"); + expect((await agent.runs.get("empty-failure"))?.checkpoints.at(-1)).toMatchObject({ + kind: "failed", + result: "", + }); +}); + +test("permission run across destination restart exposes interrupted evidence without replaying its prompt", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-interrupted-run-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => first.close()); + const origin = await api(first); + const agent = await origin.paseo.agents.create({ + cwd: first.staticDir, + config: { provider: "codex/test", modeId: "default" }, + }); + await agent.send('printf "ok" > interrupted.txt', { messageId: "unfinished" }); + await expect.poll(async () => (await agent.runs.get("unfinished"))?.status).toBe("permission"); + const before = (await agent.runs.get("unfinished"))!; + await first.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(agent.id); + const run = await restored.runs.get("unfinished"); + expect(run).toMatchObject({ + agent: agent.address, + submissionId: "unfinished", + turnId: before.turnId, + status: "interrupted", + }); + expect(run?.checkpoints.slice(0, -1)).toEqual(before.checkpoints); + expect(run?.checkpoints.at(-1)).toMatchObject({ kind: "interrupted" }); + expect(await restored.runs.get("unfinished")).toEqual(run); +}); + +test("provider admission survives a journal write failure without being reported as a rejected turn", async () => { + let journalPath = ""; + let release!: () => void; + const terminalGate = new Promise((resolve) => { + release = resolve; + }); + cleanup.push(async () => release()); + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn: () => { + rmSync(journalPath); + mkdirSync(journalPath); + }, + beforeTurnComplete: () => terminalGate, + }), + }); + cleanup.push(() => instance.close()); + const { paseo } = await api(instance); + const agent = await paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + journalPath = join( + instance.paseoHome, + "agent-runs", + `${createHash("sha256").update(agent.id).digest("hex")}.json`, + ); + await agent.send("Reply with ADMITTED_ONCE", { messageId: "accepted-with-storage-failure" }); + const run = await agent.runs.get("accepted-with-storage-failure"); + expect(run).toMatchObject({ status: "running", turnId: expect.any(String) }); + expect(run?.checkpoints.map((checkpoint) => checkpoint.kind)).not.toContain("failed"); + rmSync(journalPath, { recursive: true }); + release(); + await expect + .poll(async () => (await agent.runs.get("accepted-with-storage-failure"))?.status) + .toBe("completed"); +}); + +test("terminal checkpoint storage failure preserves provider completion and retries durable evidence", async () => { + let journalPath = ""; + let release!: () => void; + let reached!: () => void; + const ready = new Promise((resolve) => { + reached = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + cleanup.push(async () => release()); + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeTurnComplete: async () => { + reached(); + await gate; + await rm(journalPath); + await mkdir(journalPath); + }, + }), + }); + cleanup.push(() => instance.close()); + const { paseo } = await api(instance); + const agent = await paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + journalPath = join( + instance.paseoHome, + "agent-runs", + `${createHash("sha256").update(agent.id).digest("hex")}.json`, + ); + await agent.send("Reply with TERMINAL_EVIDENCE", { messageId: "terminal-storage-failure" }); + await ready; + const finished = agent.waitForFinish(2000); + release(); + expect((await finished).status).toBe("idle"); + expect(await agent.runs.get("terminal-storage-failure")).toMatchObject({ + status: "completed", + durability: "pending", + }); + await rm(journalPath, { recursive: true }); + expect(await agent.runs.get("terminal-storage-failure")).toMatchObject({ + status: "completed", + durability: "persisted", + }); +}); + +test("creation cancellation survives destination restart and fences delayed submissions by reserved identity", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-create-cancel-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => first.close()); + const origin = await api(first); + const agentId = crypto.randomUUID(); + const idempotencyKey = "reserved-before-admission"; + expect(await origin.paseo.creations.cancelAgent({ agentId, idempotencyKey })).toEqual({ + status: "canceled", + reservedAgent: { serverId: first.daemon.getServerId(), agentId }, + }); + await expect( + origin.paseo.creations.cancelAgent({ agentId: crypto.randomUUID(), idempotencyKey }), + ).rejects.toMatchObject({ code: "agent_request_key_conflict" }); + const viewer = createHostClient(`127.0.0.1:${first.port}`, { + clientId: crypto.randomUUID(), + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + cleanup.push(() => viewer.close()); + await viewer.connect(); + const viewerApi = createPaseoApi(viewer); + cleanup.push(() => viewerApi.dispose()); + await expect( + viewerApi.creations.cancelAgent({ + agentId: crypto.randomUUID(), + idempotencyKey: "unauthorized-cancellation", + }), + ).rejects.toMatchObject({ code: "access_denied" }); + await first.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + await expect( + next.paseo.agents.create({ + agentId, + idempotencyKey, + cwd: restarted.staticDir, + config: { provider: "codex/test" }, + prompt: "Never execute this delayed submission", + }), + ).rejects.toMatchObject({ code: "creation_canceled" }); + await expect( + next.paseo.agents.create({ + agentId: crypto.randomUUID(), + idempotencyKey, + cwd: restarted.staticDir, + config: { provider: "codex/test" }, + }), + ).rejects.toMatchObject({ code: "agent_request_key_conflict" }); + expect(await next.paseo.agents.ref(agentId).refresh()).toBeNull(); + expect(await next.paseo.creations.cancelAgent({ agentId, idempotencyKey })).toMatchObject({ + status: "canceled", + }); +}); + +test("creation cancellation reports admitted work before provider resource readiness and never overwrites its runner", async () => { + let release!: () => void; + let reached!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + reached = resolve; + }); + cleanup.push(async () => release()); + let prompts = 0; + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (!config.internal) { + reached(); + await gate; + } + }, + onStartTurn: (_prompt, config) => { + if (!config.internal) prompts++; + }, + }), + }); + cleanup.push(() => instance.close()); + const { paseo } = await api(instance); + const agentId = crypto.randomUUID(); + const idempotencyKey = "admitted-before-cancel"; + const creating = paseo.agents.create({ + agentId, + idempotencyKey, + cwd: instance.staticDir, + config: { provider: "codex/test" }, + prompt: "Execute exactly once", + }); + void creating.catch(() => {}); + await ready; + const cancellation = await paseo.creations.cancelAgent({ agentId, idempotencyKey }); + expect(cancellation).toMatchObject({ + status: "admitted", + reservedAgent: { serverId: instance.daemon.getServerId(), agentId }, + creation: { agentId, phase: "accepted" }, + }); + expect(cancellation.status === "admitted" && cancellation.creation.agent).toBeUndefined(); + release(); + await creating; + expect(await paseo.creations.cancelAgent({ agentId, idempotencyKey })).toMatchObject({ + status: "admitted", + creation: { phase: "completed" }, + }); + expect(prompts).toBe(1); + const observation = paseo.creations.observeAgent(idempotencyKey); + expect((await observation.ready).snapshot).toMatchObject({ phase: "completed", agentId }); + await observation.release(); +}); + +test("parent archive cancels an unadmitted durable creation after origin restart without a stray child", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-unadmitted-child-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const b = await daemon(); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await expect( + parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "unadmitted", + prompt: "Must never be replayed", + }), + ).rejects.toMatchObject({ code: "host_disabled" }); + const row = (await parent.subagents.list()).rows[0]!; + expect(row.phase).toBe("creating"); + expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull(); + await parent.archive(); + expect((await parent.subagents.list()).rows[0]?.phase).toBe("detach_pending"); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(parent.id); + await next.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(next.paseo); + await expect.poll(async () => (await restored.subagents.list()).rows[0]?.phase).toBe("canceled"); + expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull(); + await next.paseo.hosts.remove(saved.registrationId); +}); + +test("parent lifecycle recovers an admitted creation that becomes ready after origin restart", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-admitted-child-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + let release!: () => void; + let reached!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + reached = resolve; + }); + cleanup.push(async () => release()); + const b = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (!config.internal) { + reached(); + await gate; + } + }, + }), + }); + cleanup.push(() => b.close()); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const creating = parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "admitted-child", + prompt: "Run once after admission", + }); + void creating.catch(() => {}); + await ready; + const row = (await parent.subagents.list()).rows[0]!; + await parent.archive(); + expect((await parent.subagents.list()).rows[0]).toMatchObject({ + phase: "detach_pending", + error: { code: "creation_pending" }, + }); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + await online(next.paseo); + const restored = next.paseo.agents.ref(parent.id); + await expect + .poll(async () => (await restored.subagents.list()).rows[0]?.error?.code) + .toBe("creation_pending"); + release(); + await expect.poll(async () => (await restored.subagents.list()).rows[0]?.phase).toBe("detached"); + expect((await destination.paseo.agents.ref(row.child.agentId).refresh())?.agent).toMatchObject({ + parent: null, + archivedAt: null, + }); +}); + +test("failed admitted creation exposes its failure through lifecycle recovery and forget releases its receipt observation", async () => { + const a = await daemon(); + let attempts = 0; + const b = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (!config.internal) { + attempts++; + throw new Error("Provider startup rejected before resource creation"); + } + }, + }), + }); + cleanup.push(() => b.close()); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await online(origin.paseo); + const parent = await origin.paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + await expect( + parent.subagents.create({ + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "failed-admitted", + }), + ).rejects.toThrow("Provider startup rejected"); + const row = (await parent.subagents.list()).rows[0]!; + await parent.archive(); + expect((await parent.subagents.list()).rows[0]).toMatchObject({ + phase: "detach_pending", + freshness: "current", + error: { + code: "creation_failed", + message: expect.stringContaining("Provider startup rejected"), + }, + }); + expect(attempts).toBe(1); + await parent.subagents.forget(row.child); + await expect + .poll(async () => + Number( + /Host runtimes: (\d+)/.exec((await origin.client.collectDiagnostics()).diagnostic)![1], + ), + ) + .toBe(0); + expect((await parent.subagents.list()).rows).toEqual([]); + expect(attempts).toBe(1); +}); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 45f4408d4a..4ca78b265d 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -60,6 +60,7 @@ export class ConnectedHosts { constructor( private readonly home: string, private readonly self: HostDirectory["self"], + private readonly assertRemovable: (serverId: string) => Promise = async () => {}, ) { this.ready = this.load(); void this.ready.catch(() => {}); @@ -215,6 +216,7 @@ export class ConnectedHosts { "permission_denied", "Only the source or access manager can remove this host. Disable it to stop access.", ); + if (row.serverId) await this.assertRemovable(row.serverId); const rows = this.rows.filter((entry) => entry !== row); await this.persist(rows); this.stop(id, "host_unknown"); diff --git a/packages/server/src/server/connected-hosts/session.ts b/packages/server/src/server/connected-hosts/session.ts index 811daaa723..b7d71a080f 100644 --- a/packages/server/src/server/connected-hosts/session.ts +++ b/packages/server/src/server/connected-hosts/session.ts @@ -74,8 +74,8 @@ export class ConnectedHostSession { "Host operation failed. Check the connection and retry.", ); if (error instanceof HostError) failure = error; - else if (error instanceof Error && "code" in error && error.code === "invalid_connection") - failure = new HostError("invalid_connection", error.message); + else if (error instanceof Error && "code" in error && typeof error.code === "string") + failure = new HostError(error.code, error.message); if (message.type === "hosts.route.frame.request") { const route = this.routes.get(message.routeId); if (route && route.source === this.delivery.currentSource) diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index a165110ef8..49ce95d293 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -322,3 +322,40 @@ test("observer failures are logged without failing creation", async () => { }), ); }); + +test("cancellation preserves an admitted outcome-unknown receipt after owner restart", async () => { + const f = await fixture(); + const entered = deferred(); + const input: CreationInput = { + kind: "agent", + key: "interrupted-admission", + request: { task: "start exactly once" }, + agentId: agent.id, + hasAgent: true, + hasPrompt: true, + exists: async () => false, + readAgent: async () => null, + createAgent: async () => { + entered.resolve(); + return new Promise(() => {}); + }, + }; + void f.service.create(input); + await entered.promise; + const restarted = new CreationService(f.directory, silentLogger); + expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({ + status: "admitted", + creation: { phase: "accepted", agentId: agent.id }, + }); + const observation = await restarted.subscribe("agent", input.key, () => {}); + expect(observation.snapshot).toMatchObject({ + phase: "failed", + failedStage: "agent", + outcomeUnknown: true, + }); + expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({ + status: "admitted", + creation: { phase: "failed", outcomeUnknown: true }, + }); + observation.unsubscribe(); +}); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index e0b28b8069..9f3974b82b 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -65,6 +65,8 @@ export class CreationService { const identity = identityFor(input.kind, input.key); const admitted = this.admission.then(async () => { await mkdir(this.directory, { recursive: true }); + const canceled = await this.canceledSubmission(input, identity); + if (canceled) return { completion: Promise.resolve(canceled), snapshot: canceled }; const fingerprint = digest(input.request); let record = await this.read(identity); if (!record) record = await this.readLegacyAgent(input, fingerprint); @@ -122,6 +124,62 @@ export class CreationService { } } + private async canceledSubmission( + input: CreationInput, + identity: string, + ): Promise { + if (input.kind !== "agent") return null; + const agentId = await this.readCancellation(identity); + if (!agentId) return null; + if (input.agentId && input.agentId !== agentId) throw conflict(); + return cancellationSnapshot(input.key, agentId); + } + + /** Fences a creation identity only before admission. Running receipts remain owned by their runner. */ + cancelAgent( + key: string, + agentId: string, + ): Promise<{ status: "canceled" } | { status: "admitted"; creation: CreationSnapshot }> { + const identity = identityFor("agent", key); + const operation = this.admission.then(async () => { + await mkdir(this.directory, { recursive: true }); + const record = await this.read(identity); + if (record) { + if (record.snapshot.agentId !== agentId) throw conflict(); + return { status: "admitted" as const, creation: record.snapshot }; + } + // COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire. + // A legacy receipt is evidence of admission, never permission to cancel an uncertain runner. + if ( + this.legacyDirectory && + (await readOptional(join(this.legacyDirectory, `${digest(["create", key])}.json`))) !== null + ) + throw Object.assign(new Error("Cannot cancel an existing legacy creation receipt"), { + code: "creation_already_admitted", + }); + const canceledAgentId = await this.readCancellation(identity); + if (canceledAgentId && canceledAgentId !== agentId) throw conflict(); + await writeFileAtomic( + join(this.directory, `${identity}.canceled.json`), + JSON.stringify({ agentId }), + ); + const snapshot = cancellationSnapshot(key, agentId); + for (const observer of this.observers.get(identity) ?? []) this.notify(observer, snapshot); + return { status: "canceled" as const }; + }); + this.admission = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + private async readCancellation(identity: string): Promise { + const text = await readOptional(join(this.directory, `${identity}.canceled.json`)); + return text === null + ? null + : z.object({ agentId: z.string().uuid() }).parse(JSON.parse(text)).agentId; + } + async subscribe( kind: CreationSnapshot["kind"], key: string, @@ -141,7 +199,12 @@ export class CreationService { }); } if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); - return { snapshot: record?.snapshot ?? null, unsubscribe }; + const canceledAgentId = kind === "agent" ? await this.readCancellation(identity) : null; + return { + snapshot: + record?.snapshot ?? (canceledAgentId ? cancellationSnapshot(key, canceledAgentId) : null), + unsubscribe, + }; } catch (error) { unsubscribe(); throw error; @@ -375,3 +438,22 @@ async function readOptional(file: string): Promise { throw error; } } + +function conflict(): Error { + return Object.assign(new Error("The creation key already reserves a different agent identity"), { + code: "agent_request_key_conflict", + }); +} +function cancellationSnapshot(key: string, agentId: string): CreationSnapshot { + return { + kind: "agent", + idempotencyKey: key, + revision: 0, + phase: "failed", + workspaceId: null, + agentId, + error: "Creation was canceled before admission", + errorCode: "creation_canceled", + failedStage: "agent", + }; +} diff --git a/packages/server/src/server/host-runtime/index.ts b/packages/server/src/server/host-runtime/index.ts index 866eedbdda..878115ea83 100644 --- a/packages/server/src/server/host-runtime/index.ts +++ b/packages/server/src/server/host-runtime/index.ts @@ -6,6 +6,7 @@ import type { DaemonPermission } from "@getpaseo/protocol/messages"; import type { SessionAdmission } from "../websocket-server.js"; export interface HostRuntimeAuthority { + sourceId?: string; permissionCeiling: readonly DaemonPermission[]; resolveAdmission(): Promise; } @@ -103,21 +104,25 @@ class OwnedHostRuntime extends HostRuntime { override dispose(): Promise { if (this.ownedDisposal) return this.ownedDisposal; - this.ownedDisposal = (async () => { - try { - await super.dispose(); - } finally { - try { - await this.originApi.dispose(); - } finally { - try { - await this.originClient.close(); - } finally { - this.onDisposed(this); - } - } - } - })(); + this.stopAcquisitions(); + this.ownedDisposal = Promise.resolve().then(() => this.disposeOwnedResources()); return this.ownedDisposal; } + private async disposeOwnedResources(): Promise { + try { + try { + // This adapter owns the transport. Stop it before dependent releases + // that cannot receive acknowledgments through a stopping daemon. + await this.originClient.close(); + } finally { + await super.dispose(); + } + } finally { + try { + await this.originApi.dispose(); + } finally { + this.onDisposed(this); + } + } + } } diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 84726d0b02..77f75e8bb8 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1266,6 +1266,7 @@ export class HubRelationshipHarness { pino({ level: "silent" }), ); const manager = new AgentManager({ + serverId: "srv_test", clients: createTestAgentClients(), registry: storage, logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 1a9f0f5fd4..5584bd322d 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -341,7 +341,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -383,7 +383,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -435,7 +435,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -462,7 +462,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -502,7 +502,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -537,7 +537,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -586,7 +586,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -615,7 +615,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -651,7 +651,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -684,6 +684,7 @@ describe("ScheduleService", () => { test("executes new-agent schedules through AgentManager with real fake clients", async () => { const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -724,6 +725,7 @@ describe("ScheduleService", () => { test("delivers agent-target schedules through the steer-or-interrupt path", async () => { const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -758,6 +760,7 @@ describe("ScheduleService", () => { test("titles scheduled new agents from the schedule prompt", async () => { const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -801,7 +804,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, createDirectoryWorkspace: createScheduleDirectoryWorkspace, @@ -838,6 +841,7 @@ describe("ScheduleService", () => { createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -906,6 +910,7 @@ describe("ScheduleService", () => { createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -970,6 +975,7 @@ describe("ScheduleService", () => { createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1024,7 +1030,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -1087,7 +1093,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -1129,6 +1135,7 @@ describe("ScheduleService", () => { const createdInputs: Parameters[0][] = []; const runPrompts: AgentPromptInput[] = []; const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1198,6 +1205,7 @@ describe("ScheduleService", () => { test("scheduled new-agent run output falls back to final text and curated timeline", async () => { let runCount = 0; const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1278,6 +1286,7 @@ describe("ScheduleService", () => { test("scheduled new-agent cancellations fail the run", async () => { const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1345,6 +1354,7 @@ describe("ScheduleService", () => { logger.child = (() => logger) as typeof logger.child; const archiveError = new Error("archive exploded"); const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1552,6 +1562,7 @@ describe("ScheduleService", () => { } const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: { claude: new PromptEchoScheduleClient() }, registry: agentStorage, @@ -1736,6 +1747,7 @@ describe("ScheduleService", () => { const client = new CountingScheduleClient(); const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: { claude: client }, registry: agentStorage, @@ -1838,6 +1850,7 @@ describe("ScheduleService", () => { } const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: { claude: { @@ -1893,6 +1906,7 @@ describe("ScheduleService", () => { test("defaults new-agent modeId to provider's unattended mode", async () => { const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, @@ -1962,6 +1976,7 @@ describe("ScheduleService", () => { isAvailable: () => opencodeClient.isAvailable(), } satisfies AgentClient; const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients, registry: agentStorage, @@ -2033,6 +2048,7 @@ describe("ScheduleService", () => { isAvailable: () => claudeClient.isAvailable(), } satisfies AgentClient; const manager = new AgentManager({ + serverId: "srv_test", logger: createTestLogger(), clients, providerDefinitions: { claude: TEST_CLAUDE_PROVIDER_DEFINITION }, @@ -2118,7 +2134,7 @@ describe("ScheduleService", () => { const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2143,7 +2159,7 @@ describe("ScheduleService", () => { const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2160,7 +2176,7 @@ describe("ScheduleService", () => { const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2189,7 +2205,7 @@ describe("ScheduleService", () => { const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2205,7 +2221,7 @@ describe("ScheduleService", () => { const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2249,7 +2265,7 @@ describe("ScheduleService", () => { const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2274,7 +2290,7 @@ describe("ScheduleService", () => { const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2317,7 +2333,7 @@ describe("ScheduleService", () => { const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2351,7 +2367,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2396,7 +2412,7 @@ describe("ScheduleService", () => { }); test("rejects archived target agents before loading them", async () => { - const manager = new AgentManager({ logger: createTestLogger() }); + const manager = new AgentManager({ serverId: "srv_test", logger: createTestLogger() }); const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), @@ -2461,7 +2477,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2484,7 +2500,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2508,7 +2524,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2531,7 +2547,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2555,7 +2571,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2591,7 +2607,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2652,7 +2668,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2685,7 +2701,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2718,7 +2734,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2750,7 +2766,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2776,7 +2792,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2801,7 +2817,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2846,7 +2862,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2881,7 +2897,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2907,7 +2923,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -2974,7 +2990,7 @@ describe("ScheduleService", () => { const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3018,7 +3034,7 @@ describe("ScheduleService", () => { const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3041,7 +3057,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3073,7 +3089,7 @@ describe("ScheduleService", () => { service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3104,7 +3120,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3136,7 +3152,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3171,7 +3187,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3207,7 +3223,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3232,7 +3248,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3270,7 +3286,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3301,7 +3317,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3324,7 +3340,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3366,7 +3382,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3401,7 +3417,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3456,7 +3472,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3482,7 +3498,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3520,7 +3536,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3550,7 +3566,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, @@ -3596,7 +3612,7 @@ describe("ScheduleService", () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), + agentManager: new AgentManager({ serverId: "srv_test", logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 00d90c7729..c4c61bbfd1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,5 @@ +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import type { Subagents, SubagentAuthority } from "./subagents/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; @@ -444,6 +446,8 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + subagents?: Subagents; + subagentAuthority?: () => SubagentAuthority; connectedHosts?: ConnectedHosts; sourceId?: string; routed?: boolean; @@ -469,7 +473,7 @@ export interface SessionOptions { agentManager: AgentManager; agentStorage: AgentStorage; messageReceipts: Pick; - creationService: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -681,6 +685,10 @@ interface ClientActivity { } export class Session { + private readonly subagentOptions: Pick< + SessionOptions, + "subagents" | "subagentAuthority" | "serverId" + >; private readonly connectedHostSession: ConnectedHostSession | null; private routed = false; isRouted(): boolean { @@ -823,7 +831,7 @@ export class Session { private readonly workspaceScripts: WorkspaceScriptsService; private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; - private readonly creationService: Pick; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -885,6 +893,7 @@ export class Session { this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); + this.subagentOptions = options; this.appVersion = appVersion ?? null; this.clientCapabilities = parseClientCapabilities(clientCapabilities); @@ -2247,6 +2256,118 @@ export class Session { } } + private async handleSubagents( + message: Extract< + SessionInboundMessage, + { + type: + | "agent.subagents.create.request" + | "agent.subagents.list.request" + | "agent.subagents.detach.request" + | "agent.subagents.forget.request"; + } + >, + ): Promise { + try { + await this.executeSubagents(message); + } catch (error) { + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: error instanceof Error && "code" in error ? String(error.code) : "subagent_error", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private async executeSubagents( + message: Extract< + SessionInboundMessage, + { + type: + | "agent.subagents.create.request" + | "agent.subagents.list.request" + | "agent.subagents.detach.request" + | "agent.subagents.forget.request"; + } + >, + ): Promise { + const { subagents, subagentAuthority, serverId } = this.subagentOptions; + if (!subagents || !subagentAuthority || !serverId) + throw new Error("Subagent relationships are not available"); + const parent = { serverId, agentId: message.parentId }; + if (message.type === "agent.subagents.create.request") { + const child = await subagents.create(parent, message, subagentAuthority()); + this.emit({ + type: "agent.subagents.create.response", + payload: { requestId: message.requestId, child }, + }); + return; + } + if (message.type === "agent.subagents.detach.request") { + const relationship = await subagents.detach(parent, message.child, subagentAuthority()); + this.emit({ + type: "agent.subagents.detach.response", + payload: { requestId: message.requestId, relationship }, + }); + return; + } + if (message.type === "agent.subagents.forget.request") { + await subagents.forget(parent, message.child, subagentAuthority()); + this.emit({ + type: "agent.subagents.forget.response", + payload: { requestId: message.requestId }, + }); + return; + } + // The session's workspace-read permission authorizes the origin parent directory. + if ( + !(await this.agentStorage.get(parent.agentId)) && + !this.agentManager.getAgent(parent.agentId) + ) + throw new Error("Parent agent not found"); + if (!message.observe) { + this.emit({ + type: "agent.subagents.list.response", + payload: { + requestId: message.requestId, + directory: await subagents.list(parent, subagentAuthority()), + }, + }); + return; + } + let stop = () => {}; + const owner = this.delivery.begin("subagents", undefined, () => stop(), parent.agentId); + try { + stop = subagents.observe(() => { + void subagents + .list(parent, subagentAuthority()) + .then((directory) => { + owner.emit({ + type: "agent.subagents.changed", + payload: { subscriptionId: owner.id, directory }, + }); + return undefined; + }) + .catch(() => owner.release()); + }); + this.emit({ + type: "agent.subagents.list.response", + payload: { + requestId: message.requestId, + subscriptionId: owner.id, + directory: await subagents.list(parent, subagentAuthority()), + }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); this.connectedHostSession?.revoke(); @@ -2828,6 +2949,8 @@ export class Session { source?: object, ): Promise | undefined { switch (msg.type) { + case "agent.create.cancel.request": + return this.handleAgentCreationCancel(msg); case "agent.create.request": return this.handleAgentCreation(msg, source); case "creation.subscribe.request": @@ -2839,7 +2962,64 @@ export class Session { } } + private async handleAgentCreationCancel( + message: Extract, + ): Promise { + try { + // The originating execution-scoped capability cannot be reduced to ordinary grants. + this.subagentOptions.subagentAuthority?.(); + const result = await this.creationService.cancelAgent( + message.idempotencyKey, + message.agentId, + ); + const serverId = this.subagentOptions.serverId; + if (!serverId) throw new Error("Daemon identity is unavailable"); + this.emit({ + type: "agent.create.cancel.response", + payload: { + requestId: message.requestId, + cancellation: { ...result, reservedAgent: { serverId, agentId: message.agentId } }, + }, + }); + } catch (error) { + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: + error instanceof Error && "code" in error + ? String(error.code) + : "creation_cancel_failed", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private async handleAgentRunGet( + message: Extract, + ): Promise { + const run = await this.agentManager.getAgentRun(message.agentId, message.submissionId); + this.emit({ type: "agent.runs.get.response", payload: { requestId: message.requestId, run } }); + } + + private dispatchSubagentMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "agent.runs.get.request") return this.handleAgentRunGet(msg); + if ( + msg.type === "agent.subagents.create.request" || + msg.type === "agent.subagents.list.request" || + msg.type === "agent.subagents.detach.request" || + msg.type === "agent.subagents.forget.request" + ) + return this.handleSubagents(msg); + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { + return this.dispatchSubagentMessage(msg) ?? this.dispatchCoreAgentLifecycleMessage(msg); + } + + private dispatchCoreAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": return this.handleFetchAgents(msg); @@ -4155,6 +4335,7 @@ export class Session { requestId, agent: null, error: error instanceof Error ? error.message : String(error), + errorCode: error instanceof Error && "code" in error ? String(error.code) : undefined, }, }, source, @@ -4355,11 +4536,50 @@ export class Session { } } + private async createLegacySubagent( + msg: CreateAgentRequestMessage, + agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, + ): Promise { + // COMPAT(qualifiedSubagents): normalize pre-fleet caller/label creation at the session edge. + // Added in v0.12; remove after 2027-04-20 once all creation callers use parent capabilities. + const legacyParentId = msg.callerAgentId ?? msg.labels[PARENT_AGENT_ID_LABEL]; + const { subagents, subagentAuthority, serverId } = this.subagentOptions; + if (legacyParentId && subagents && subagentAuthority && serverId) { + const { + type: _type, + requestId: _requestId, + callerAgentId: _caller, + parent: _parent, + ...input + } = msg; + const labels = { ...input.labels }; + delete labels[PARENT_AGENT_ID_LABEL]; + const child = await subagents.create( + { serverId, agentId: legacyParentId }, + { + serverId, + idempotencyKey: msg.idempotencyKey ?? msg.requestId, + input: { ...input, labels, agentId }, + }, + subagentAuthority(), + ); + const record = await this.agentStorage.get(child.agentId); + if (!record) throw new Error("Created subagent is not available"); + const agent = this.buildStoredAgentPayload(record); + await onAgentReady?.(agent); + return { agent, initialPromptStarted: hasInitialCreationPrompt(msg) }; + } + return null; + } + private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, onAgentReady?: (agent: AgentSnapshotPayload) => Promise, ): Promise { + const delegated = await this.createLegacySubagent(msg, agentId, onAgentReady); + if (delegated) return delegated; const { config, worktreeName, @@ -4450,6 +4670,7 @@ export class Session { images, attachments, git, + parent: msg.parent, labels: resolvedIntent.intent.labels, env, provisionalTitle, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index fafcbc182a..bd6c58fd6e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -958,6 +958,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CreateAgentTestClient() }, registry: agentStorage, logger: asSessionLogger(logger), @@ -1113,6 +1114,7 @@ test("create_agent_request launches from an exact subdirectory in a created work }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CreateAgentTestClient() }, registry: agentStorage, logger: asSessionLogger(logger), @@ -1262,6 +1264,7 @@ test("create_agent_request does not title an existing workspace from the agent p }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); const agentManager = new AgentManager({ + serverId: "srv_test", clients: { codex: new CreateAgentTestClient() }, registry: agentStorage, logger: asSessionLogger(logger), diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index f17e2a2067..87af6a9a8a 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -123,6 +123,7 @@ export function matchesAgentUpdatesFilter(input: { filter?: AgentUpdatesFilter; }): boolean { const { agent, project, filter } = input; + if (filter?.agentIds && !filter.agentIds.includes(agent.id)) return false; if (filter?.labels) { const matchesLabels = Object.entries(filter.labels).every( diff --git a/packages/server/src/server/subagents/index.ts b/packages/server/src/server/subagents/index.ts new file mode 100644 index 0000000000..e7edf3bf3d --- /dev/null +++ b/packages/server/src/server/subagents/index.ts @@ -0,0 +1,814 @@ +import type { HostDirectory } from "@getpaseo/protocol/hosts"; +import { shouldDetachFromArchivedParent } from "@getpaseo/protocol/agent-lifecycle"; +import { hasOpenAgentTab } from "@getpaseo/protocol/agent-labels"; +import { WorkspaceAddressSchema, type WorkspaceAddress } from "@getpaseo/protocol/addresses"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import type { HostRuntime } from "@getpaseo/client"; +import { AgentAddressSchema, type AgentAddress } from "@getpaseo/protocol/addresses"; +import { + DaemonPermissionSchema, + SubagentCreateInputSchema, + SubagentRelationshipSchema, + type DaemonPermission, + type SubagentCreateInput, + type SubagentDirectory, +} from "@getpaseo/protocol/messages"; +import { writeJsonFileAtomic } from "../atomic-file.js"; +import type { HostRuntimeAuthority } from "../host-runtime/index.js"; + +/** Stored authority contains identity and a ceiling; live admission belongs to the host. */ +export interface SubagentAuthority { + principalId: string; + sourceId: string; + permissionCeiling: readonly DaemonPermission[]; +} +const AuthoritySchema = z.object({ + principalId: z.string(), + sourceId: z.string(), + permissionCeiling: z.array(DaemonPermissionSchema), +}); +const RecordSchema = z.object({ + row: SubagentRelationshipSchema, + authority: AuthoritySchema, + creation: SubagentCreateInputSchema, + fingerprint: z.string(), + confirmed: z.enum(["attached", "detached", "archived", "canceled"]).nullable(), + intent: z.object({ + revision: z.number().int().nonnegative(), + kind: z.enum(["attached", "detach", "archive-parent", "forgotten"]), + parentWorkspace: WorkspaceAddressSchema.optional(), + authority: AuthoritySchema, + }), +}); +type RelationshipRecord = z.infer; +type RelationshipIntent = RelationshipRecord["intent"]; + +/** Authoritative parent membership, independent of views and destination metadata. */ +export class Subagents { + private readonly records = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly settling = new Map>(); + private readonly startingObservers = new Map>(); + private readonly running = new Map>(); + private readonly activeAuthorities = new Map(); + private readonly runtimes = new Map>(); + private readonly observers = new Map< + string, + { + intent: RelationshipIntent; + release(): Promise; + creation?: () => CreationSnapshot | null; + } + >(); + private recovering: Promise | null = null; + private recoveryRequested = false; + private mutation: Promise = Promise.resolve(); + private readonly ready: Promise; + private closed = false; + + constructor( + private readonly options: { + home: string; + serverId: string; + runtime(authority: HostRuntimeAuthority): Promise; + resolveAuthority(principalId: string): ReturnType; + }, + ) { + this.ready = this.load(); + void this.ready.catch(() => {}); + } + + private async load(): Promise { + let records: RelationshipRecord[]; + try { + records = z.array(RecordSchema).parse(JSON.parse(await readFile(this.path(), "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + for (const record of records) { + record.row.freshness = "stale"; + this.records.set(record.row.id, record); + } + } + private path(): string { + return join(this.options.home, "subagents.json"); + } + private transact(operation: () => Promise): Promise { + const result = this.mutation.then(async () => { + await this.ready; + if (this.closed) throw new Error("Subagent relationships are closed"); + return operation(); + }); + this.mutation = result.catch(() => {}); + return result; + } + private async persist(): Promise { + for (const record of this.records.values()) record.row.phase = relationshipPhase(record); + await writeJsonFileAtomic(this.path(), [...this.records.values()]); + for (const changed of this.listeners) changed(); + } + async assertHostRemovable(serverId: string): Promise { + await this.ready; + if ( + [...this.records.values()].some( + ({ row }) => row.child.serverId === serverId && !terminalPhase(row.phase), + ) + ) + throw failure( + "relationships_pending", + "Detach children or explicitly forget their relationships before removing this host", + ); + } + observe(changed: () => void): () => void { + this.listeners.add(changed); + return () => { + this.listeners.delete(changed); + }; + } + async list(parent: AgentAddress, authority: SubagentAuthority): Promise { + await this.authorize(authority, "workspace.read"); + await this.ready; + return { + parent: AgentAddressSchema.parse(parent), + rows: [...this.records.values()] + .filter(({ row }) => sameAddress(row.parent, parent) && row.phase !== "forgotten") + .map(({ row }) => structuredClone(row)), + }; + } + private authorityKey(authority: SubagentAuthority): string { + return JSON.stringify([ + authority.principalId, + authority.sourceId, + [...authority.permissionCeiling].sort(), + ]); + } + private async usingAuthority( + authority: SubagentAuthority, + operation: () => Promise, + ): Promise { + const key = this.authorityKey(authority); + this.activeAuthorities.set(key, (this.activeAuthorities.get(key) ?? 0) + 1); + try { + return await operation(); + } finally { + const remaining = this.activeAuthorities.get(key)! - 1; + if (remaining) this.activeAuthorities.set(key, remaining); + else this.activeAuthorities.delete(key); + await this.releaseUnusedRuntimes(); + } + } + private async releaseUnusedRuntimes(): Promise { + const used = new Set(this.activeAuthorities.keys()); + for (const record of this.records.values()) { + if (terminalPhase(record.row.phase)) continue; + used.add(this.authorityKey(record.authority)); + used.add(this.authorityKey(record.intent.authority)); + } + const releases: Promise[] = []; + for (const [key, runtime] of this.runtimes) { + if (used.has(key)) continue; + this.runtimes.delete(key); + releases.push(runtime.then((scope) => scope.dispose())); + } + await Promise.allSettled(releases); + } + private runtime(authority: SubagentAuthority): Promise { + if (this.closed) throw new Error("Subagent relationships are closed"); + const key = this.authorityKey(authority); + let pending = this.runtimes.get(key); + if (!pending) { + pending = this.options.runtime({ + sourceId: authority.sourceId, + permissionCeiling: authority.permissionCeiling, + resolveAdmission: () => this.options.resolveAuthority(authority.principalId), + }); + this.runtimes.set(key, pending); + const acquiring = pending; + void acquiring.catch(() => { + if (this.runtimes.get(key) === acquiring) this.runtimes.delete(key); + }); + } + return pending; + } + private async authorize( + authority: SubagentAuthority, + permission: DaemonPermission, + ): Promise { + const admission = await this.options.resolveAuthority(authority.principalId); + if (admission.hubExecutionAgents) + throw failure( + "unsupported_capability", + "Execution-scoped subagent relationships are not supported", + ); + if ( + !admission.permissions.includes(permission) || + !authority.permissionCeiling.includes(permission) + ) + throw failure("access_denied", `Subagent relationships require ${permission}`); + } + async create( + parent: AgentAddress, + creation: SubagentCreateInput, + authority: SubagentAuthority, + ): Promise { + return this.usingAuthority(authority, () => + this.createRelationship(parent, creation, authority), + ); + } + private async createRelationship( + parent: AgentAddress, + creation: SubagentCreateInput, + authority: SubagentAuthority, + ): Promise { + parent = AgentAddressSchema.parse(parent); + if (parent.serverId !== this.options.serverId) + throw failure("invalid_parent", "Create subagents through the parent's host"); + await this.authorize(authority, "workspace.write"); + const runtime = await this.runtime(authority); + const parentHandle = await runtime.agent(parent); + const refreshed = await parentHandle.refresh(); + if (!refreshed) throw failure("invalid_parent", "Parent agent not found"); + const snapshot = refreshed.agent; + if (snapshot.archivedAt) + throw failure("invalid_parent", "Cannot attach a child to an archived parent"); + creation = SubagentCreateInputSchema.parse(creation); + let workspaceId = creation.input.workspaceId; + if (!workspaceId) { + if (creation.serverId !== parent.serverId) + throw failure("workspace_required", "Choose a workspace on the destination host"); + workspaceId = snapshot.workspaceId; + if (!workspaceId) throw failure("workspace_required", "The parent has no workspace"); + } + creation.input.workspaceId = workspaceId; + const fingerprint = JSON.stringify(creation); + const id = createHash("sha256") + .update(JSON.stringify([authority.sourceId, parent, creation.idempotencyKey])) + .digest("hex"); + const record = await this.transact(async () => { + const existing = this.records.get(id); + if (existing) { + if (existing.fingerprint !== fingerprint) + throw failure("idempotency_conflict", "This subagent creation key has different inputs"); + return existing; + } + const fresh: RelationshipRecord = { + row: { + id, + parent, + child: { serverId: creation.serverId, agentId: creation.input.agentId ?? randomUUID() }, + workspace: { serverId: creation.serverId, workspaceId }, + phase: "creating", + observedAt: null, + freshness: "stale", + error: null, + }, + authority: { ...authority, permissionCeiling: [...authority.permissionCeiling] }, + creation, + fingerprint, + confirmed: null, + intent: { + revision: 0, + kind: "attached", + authority: { ...authority, permissionCeiling: [...authority.permissionCeiling] }, + }, + }; + this.records.set(id, fresh); + try { + await this.persist(); + } catch (error) { + this.records.delete(id); + throw error; + } + return fresh; + }); + if (record.intent.kind === "forgotten") + throw failure( + "relationship_forgotten", + "This relationship was explicitly forgotten; choose a new creation key to start different work", + ); + if (record.confirmed !== null || record.intent.kind !== "attached") { + void this.observeChild(record).catch(() => {}); + return record.row.child; + } + let running = this.running.get(id); + if (!running) { + running = this.createChild(record); + this.running.set(id, running); + void running.finally(() => this.running.delete(id)).catch(() => {}); + } + return running; + } + private async createChild(record: RelationshipRecord): Promise { + const { row, creation } = record; + const creationIntent = record.intent; + try { + const runtime = await this.runtime(record.authority); + const api = await runtime.client(row.child.serverId); + try { + const workspace = api.workspaces.ref(creation.input.workspaceId!); + const { config, initialPrompt, ...input } = creation.input; + const agent = await workspace.agents.create({ + ...input, + agentId: row.child.agentId, + config: { + ...config, + provider: config.model ? `${config.provider}/${config.model}` : config.provider, + }, + parent: row.parent, + prompt: initialPrompt, + idempotencyKey: `subagent:${row.id}`, + clientMessageId: `subagent:${row.id}:initial`, + }); + await this.transact(async () => { + // Creation confirms a resource; it never owns a newer lifecycle decision. + if (!terminalConfirmation(record)) record.confirmed = "attached"; + if (record.intent.kind === "attached") this.captureSnapshot(record, agent.current()); + await this.persist(); + }); + } finally { + await api.dispose(); + } + if (record.intent.kind === "attached") void this.observeChild(record).catch(() => {}); + else void this.settleObligation(record).catch(() => {}); + return row.child; + } catch (error) { + await this.transact(async () => { + if (record.intent !== creationIntent || terminalPhase(record.row.phase)) return; + row.freshness = "unavailable"; + row.error = { + code: error instanceof Error && "code" in error ? String(error.code) : "host_error", + message: error instanceof Error ? error.message : String(error), + }; + await this.persist(); + }); + throw error; + } + } + async hostsChanged(directory: HostDirectory): Promise { + await this.ready; + const unavailable = new Set( + directory.hosts.filter((host) => host.status !== "online").map((host) => host.serverId), + ); + await this.transact(async () => { + let changed = false; + for (const record of this.records.values()) { + if (unavailable.has(record.row.child.serverId) && !terminalPhase(record.row.phase)) { + record.row.freshness = "unavailable"; + changed = true; + } + } + if (changed) await this.persist(); + }); + await this.recover(); + } + async detach(parent: AgentAddress, child: AgentAddress, authority: SubagentAuthority) { + await this.authorize(authority, "workspace.write"); + const record = await this.find(parent, child); + if (record.intent.kind === "forgotten") + throw failure("relationship_forgotten", "This relationship was forgotten"); + if (record.row.phase === "detached") return structuredClone(record.row); + await this.transact(async () => { + if (record.intent.kind === "forgotten") + throw failure("relationship_forgotten", "This relationship was forgotten"); + if (record.intent.kind !== "detach") + record.intent = { + revision: record.intent.revision + 1, + kind: "detach", + authority: { + ...authority, + permissionCeiling: authority.permissionCeiling.filter((permission) => + record.authority.permissionCeiling.includes(permission), + ), + }, + }; + await this.persist(); + }); + await this.settleObligation(record); + return structuredClone(record.row); + } + async forget( + parent: AgentAddress, + child: AgentAddress, + authority: SubagentAuthority, + ): Promise { + await this.authorize(authority, "workspace.write"); + const record = await this.find(parent, child); + await this.transact(async () => { + record.intent = { + revision: record.intent.revision + 1, + kind: "forgotten", + authority: { ...authority, permissionCeiling: [...authority.permissionCeiling] }, + }; + await this.persist(); + }); + await this.releaseObservation(record); + await this.releaseUnusedRuntimes(); + } + private async find(parent: AgentAddress, child: AgentAddress): Promise { + await this.ready; + const record = [...this.records.values()].find( + ({ row }) => sameAddress(row.parent, parent) && sameAddress(row.child, child), + ); + if (!record) + throw failure("relationship_not_found", "This child is not attached to the selected parent"); + return record; + } + /** Called by the host lifecycle, never by a mounted view. */ + recover(): Promise { + this.recoveryRequested = true; + if (this.recovering) return this.recovering; + this.recovering = (async () => { + while (this.recoveryRequested && !this.closed) { + this.recoveryRequested = false; + await this.recoverAll(); + } + })().finally(() => { + this.recovering = null; + }); + return this.recovering; + } + private async recoverAll(): Promise { + await this.ready; + if (this.closed) return; + await Promise.allSettled( + [...this.records.values()].map(async (record) => { + if (terminalPhase(record.row.phase)) return; + const runtime = await this.runtime(record.authority); + const parent = await runtime.agent(record.row.parent); + const refreshed = await parent.refresh(); + if (refreshed?.agent.archivedAt) { + await this.parentArchived( + record.row.parent, + refreshed.agent.workspaceId + ? { serverId: record.row.parent.serverId, workspaceId: refreshed.agent.workspaceId } + : undefined, + ); + return; + } + if (record.intent.kind !== "attached") { + await this.settleObligation(record); + return; + } + if (record.confirmed === null) await this.createChild(record); + await this.observeChild(record); + }), + ); + } + async parentArchived( + parent: AgentAddress, + workspace: WorkspaceAddress | undefined, + ): Promise { + await this.transact(async () => { + for (const record of this.records.values()) { + if ( + !sameAddress(record.row.parent, parent) || + record.intent.kind !== "attached" || + terminalConfirmation(record) + ) + continue; + record.intent = { + revision: record.intent.revision + 1, + kind: "archive-parent", + parentWorkspace: workspace, + authority: record.authority, + }; + } + await this.persist(); + }); + await Promise.allSettled( + [...this.records.values()] + .filter( + (record) => + sameAddress(record.row.parent, parent) && + record.intent.kind !== "attached" && + record.intent.kind !== "forgotten", + ) + .map((record) => this.settleObligation(record)), + ); + } + private observeChild(record: RelationshipRecord): Promise { + let starting = this.startingObservers.get(record.row.id); + if (!starting) { + starting = this.startObservation(record); + this.startingObservers.set(record.row.id, starting); + void starting.finally(() => this.startingObservers.delete(record.row.id)).catch(() => {}); + } + return starting; + } + private async startObservation(record: RelationshipRecord): Promise { + const intent = record.intent; + if (this.closed || !observesChild(record) || this.observers.has(record.row.id)) return; + const runtime = await this.runtime(record.authority); + const child = await runtime.agent(record.row.child); + if ( + this.closed || + record.intent !== intent || + !observesChild(record) || + this.observers.has(record.row.id) + ) + return; + const observation = child.observe(); + let applying: Promise = Promise.resolve(); + const accept = (snapshot: AgentSnapshotPayload): void => { + applying = applying + .then(async () => { + await this.transact(async () => { + if (record.intent !== intent || !observesChild(record)) return; + this.captureSnapshot(record, snapshot); + await this.persist(); + }); + if (!observesChild(record)) { + this.observers.delete(record.row.id); + await observation.release(); + await this.releaseUnusedRuntimes(); + } + return undefined; + }) + .catch(async (error) => { + if (record.intent === intent) await this.markUnavailable(record, error); + }); + }; + this.observers.set(record.row.id, { + intent, + release: async () => { + await observation.release(); + await applying; + }, + }); + observation.subscribe({ + snapshot: accept, + update: (message) => { + if ( + message.type === "agent_update" && + message.payload.kind === "upsert" && + message.payload.agent.id === record.row.child.agentId + ) + accept(message.payload.agent); + }, + error: (error) => { + void this.markUnavailable(record, error); + }, + }); + try { + await observation.ready; + await applying; + } catch (error) { + this.observers.delete(record.row.id); + await observation.release(); + if (record.intent === intent) await this.markUnavailable(record, error); + throw error; + } + } + private captureSnapshot(record: RelationshipRecord, snapshot: AgentSnapshotPayload | null): void { + if (!snapshot) return; + record.row.snapshot = snapshot; + record.row.observedAt = new Date().toISOString(); + record.row.freshness = "current"; + record.row.error = null; + if (snapshot.workspaceId) + record.row.workspace = { + serverId: record.row.child.serverId, + workspaceId: snapshot.workspaceId, + }; + if (snapshot.archivedAt) record.confirmed = "archived"; + else if (snapshot.parent === null) record.confirmed = "detached"; + else record.confirmed = "attached"; + } + private async markUnavailable(record: RelationshipRecord, error: unknown): Promise { + if (this.closed) return; + await this.transact(async () => { + record.row.freshness = "unavailable"; + record.row.error = { + code: error instanceof Error && "code" in error ? String(error.code) : "host_error", + message: error instanceof Error ? error.message : String(error), + }; + await this.persist(); + }); + } + private settleObligation(record: RelationshipRecord): Promise { + if ( + record.intent.kind === "attached" || + record.intent.kind === "forgotten" || + terminalConfirmation(record) + ) + return Promise.resolve(); + let settling = this.settling.get(record.row.id); + if (!settling) { + settling = Promise.resolve().then(() => this.settleCurrentIntent(record)); + this.settling.set(record.row.id, settling); + void settling.finally(() => this.settling.delete(record.row.id)).catch(() => {}); + } + return settling; + } + private async settleCurrentIntent(record: RelationshipRecord): Promise { + while ( + !this.closed && + record.intent.kind !== "attached" && + record.intent.kind !== "forgotten" + ) { + const intent = record.intent; + await this.performSettlement(record, intent); + if (record.intent === intent) break; + } + } + private async observeCreation( + record: RelationshipRecord, + intent: RelationshipIntent, + runtime: HostRuntime, + ): Promise { + const existing = this.observers.get(record.row.id); + if (existing?.creation && existing.intent === intent) return existing.creation(); + await existing?.release(); + this.observers.delete(record.row.id); + const api = await runtime.client(record.row.child.serverId); + if (this.closed || record.intent !== intent) { + await api.dispose(); + return null; + } + const observation = api.creations.observeAgent(`subagent:${record.row.id}`); + let latest: CreationSnapshot | null = null; + const accept = (snapshot: CreationSnapshot | null) => { + latest = snapshot; + if (record.intent === intent && (snapshot?.agent || snapshot?.phase === "failed")) + void this.recover().catch(() => {}); + }; + observation.subscribe({ + snapshot: (snapshot) => accept(snapshot.snapshot), + update: (message) => { + if (message.type === "agent.create.update") accept(message.payload); + }, + error: (error) => { + if (record.intent === intent) void this.markUnavailable(record, error); + }, + }); + const owner = { intent, creation: () => latest, release: () => api.dispose() }; + this.observers.set(record.row.id, owner); + try { + const initial = await observation.ready; + if (initial.error) throw new Error(initial.error); + if (this.closed || record.intent !== intent) { + if (this.observers.get(record.row.id) === owner) this.observers.delete(record.row.id); + await owner.release(); + } + return latest; + } catch (error) { + if (this.observers.get(record.row.id) === owner) this.observers.delete(record.row.id); + await owner.release(); + throw error; + } + } + private async releaseObservation(record: RelationshipRecord): Promise { + const observation = this.observers.get(record.row.id); + this.observers.delete(record.row.id); + await observation?.release(); + } + + private async performSettlement( + record: RelationshipRecord, + intent: RelationshipIntent, + ): Promise { + try { + await this.authorize(intent.authority, "workspace.write"); + const runtime = await this.runtime(intent.authority); + const child = await runtime.agent(record.row.child); + let current = await child.refresh(); + if (!current) { + const destination = await runtime.client(record.row.child.serverId); + try { + if (record.intent !== intent) return; + const cancellation = await destination.creations.cancelAgent({ + idempotencyKey: `subagent:${record.row.id}`, + agentId: record.row.child.agentId, + }); + if (cancellation.status === "canceled") { + await this.transact(async () => { + if (record.intent !== intent) return; + record.confirmed = "canceled"; + record.row.error = null; + record.row.freshness = "current"; + record.row.observedAt = new Date().toISOString(); + await this.persist(); + }); + await this.releaseObservation(record); + await this.releaseUnusedRuntimes(); + return; + } + // Admission reserves an identity before the resource is ready. Inspect + // its existing receipt; never resubmit an uncertain initial prompt. + const receipt = + (await this.observeCreation(record, intent, runtime)) ?? cancellation.creation; + current = await child.refresh(); + if (!current) { + await this.transact(async () => { + if (record.intent !== intent) return; + record.row.freshness = "current"; + record.row.error = + receipt.phase === "failed" + ? { + code: receipt.outcomeUnknown ? "creation_outcome_unknown" : "creation_failed", + message: receipt.error ?? "Creation failed before its child became available", + } + : { + code: "creation_pending", + message: + "Creation was admitted but its child is not yet available; reconciliation remains pending", + }; + await this.persist(); + }); + return; + } + } finally { + await destination.dispose(); + } + } + const action = await this.transact(async () => { + // Completion ownership is tested inside the same serialization boundary as the write. + if (record.intent !== intent) return null; + this.captureSnapshot(record, current.agent); + if (terminalConfirmation(record)) { + await this.persist(); + return null; + } + const detach = + intent.kind === "detach" || + shouldDetachFromArchivedParent({ + parentWorkspace: intent.parentWorkspace, + childWorkspace: record.row.workspace, + hasOpenTab: hasOpenAgentTab(current.agent.labels), + }); + await this.persist(); + return detach ? "detach" : "archive"; + }); + if (!action || record.intent !== intent) return; + // Forget can suppress future dispatch; an operation already submitted to the child cannot be undone. + if (action === "detach") await child.detach(); + else await child.archive(); + await this.transact(async () => { + if (record.intent !== intent) return; + record.confirmed = action === "detach" ? "detached" : "archived"; + record.row.error = null; + record.row.freshness = "current"; + record.row.observedAt = new Date().toISOString(); + await this.persist(); + }); + await this.releaseObservation(record); + await this.releaseUnusedRuntimes(); + } catch (error) { + if (record.intent === intent) await this.markUnavailable(record, error); + } + } + async close(): Promise { + this.closed = true; + // Owned transports stop before subscription cleanup; a stopping daemon no + // longer forwards new requests, including graceful release acknowledgments. + await Promise.allSettled( + [...this.runtimes.values()].map(async (runtime) => (await runtime).dispose()), + ); + this.runtimes.clear(); + await Promise.allSettled( + [...this.observers.values()].map((observation) => observation.release()), + ); + this.observers.clear(); + this.listeners.clear(); + await Promise.allSettled(this.running.values()); + await this.mutation; + } +} +function sameAddress(a: AgentAddress, b: AgentAddress): boolean { + return a.serverId === b.serverId && a.agentId === b.agentId; +} +function failure(code: string, message: string): Error { + return Object.assign(new Error(message), { code }); +} + +function observesChild(record: RelationshipRecord): boolean { + return record.intent.kind === "attached" && !terminalConfirmation(record); +} +function relationshipPhase(record: RelationshipRecord): RelationshipRecord["row"]["phase"] { + if (record.intent.kind === "forgotten") return "forgotten"; + if (record.confirmed !== null && record.confirmed !== "attached") return record.confirmed; + if (record.intent.kind === "detach") return "detach_pending"; + if (record.intent.kind === "archive-parent") + return shouldDetachFromArchivedParent({ + parentWorkspace: record.intent.parentWorkspace, + childWorkspace: record.row.workspace, + hasOpenTab: hasOpenAgentTab(record.row.snapshot?.labels), + }) + ? "detach_pending" + : "lifecycle_pending"; + return record.confirmed === null ? "creating" : "attached"; +} + +function terminalConfirmation(record: RelationshipRecord): boolean { + return record.confirmed !== null && record.confirmed !== "attached"; +} +function terminalPhase(phase: RelationshipRecord["row"]["phase"]): boolean { + return ( + phase === "detached" || phase === "archived" || phase === "forgotten" || phase === "canceled" + ); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index c89f35aa69..adefb96594 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { Subagents } from "./subagents/index.js"; import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; import { ConnectedHosts } from "./connected-hosts/index.js"; @@ -465,6 +466,7 @@ interface OwnedSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | OwnedSessionConnection; interface SocketSessionOptions { + subagentAuthority: NonNullable; sourceId?: string; clientId: string; appVersion: string | null; @@ -531,7 +533,10 @@ function requireWebSocketServices(params: { * WebSocket server that only accepts sockets + parses/forwards messages to the session layer. */ export class VoiceAssistantWebSocketServer { + private readonly subagents: Subagents; + private ownerAdmission: SessionAdmission = OWNER_SESSION_ADMISSION; private readonly hostRuntimes = new Set>>(); + private readonly ownedSocketSources = new WeakMap(); private readonly ownedSockets = new WeakSet(); public async createHostRuntime(authority: HostRuntimeAuthority) { @@ -551,6 +556,7 @@ export class VoiceAssistantWebSocketServer { }, ); } + if (authority.sourceId) this.ownedSocketSources.set(socket, authority.sourceId); this.ownedSockets.add(socket); await this.attachExternalSocket(socket, undefined, admission); }, @@ -740,7 +746,32 @@ export class VoiceAssistantWebSocketServer { this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; - this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); + this.connectedHosts = new ConnectedHosts( + paseoHome, + { serverId, label: getHostName() }, + (destination) => this.subagents.assertHostRemovable(destination), + ); + this.subagents = new Subagents({ + home: paseoHome, + serverId, + runtime: (authority) => this.createHostRuntime(authority), + resolveAuthority: (principalId) => this.resolveRelationshipAdmission(principalId), + }); + this.connectedHosts.observe(() => { + void this.connectedHosts + .list() + .then((directory) => this.subagents.hostsChanged(directory)) + .catch((error) => + this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"), + ); + }); + this.agentManager.setAgentArchivedCallback(async (agentId) => { + const record = await this.agentStorage.get(agentId); + await this.subagents.parentArchived( + { serverId, agentId }, + record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined, + ); + }); this.passwordHash = auth?.password; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; @@ -1084,10 +1115,19 @@ export class VoiceAssistantWebSocketServer { return { closed }; } + private async resolveRelationshipAdmission(principalId: string): Promise { + if (principalId === "owner") return this.ownerAdmission; + throw Object.assign( + new Error("This principal has no supported durable relationship authority"), + { code: "unsupported_capability" }, + ); + } + public updatePrincipalPermissions( principalId: string, permissions: readonly DaemonPermission[], ): void { + if (principalId === "owner") this.ownerAdmission = { principalId, permissions }; for (const [socket, link] of this.hostLinks) { if (this.sessions.get(socket)?.principalId === principalId) { link.close("permission_denied"); @@ -1121,10 +1161,16 @@ export class VoiceAssistantWebSocketServer { if (this.connectionLifecycle === "starting") { this.connectionLifecycle = "accepting"; } + void this.subagents + .recover() + .catch((error) => + this.logger.warn({ err: error }, "Failed to recover subagent relationships"), + ); } public async close(): Promise { this.prepareForShutdown(); + await this.subagents.close(); await Promise.allSettled([...this.hostRuntimes].map((runtime) => runtime.dispose())); this.hostRuntimes.clear(); await this.connectedHosts.close(); @@ -1536,6 +1582,19 @@ export class VoiceAssistantWebSocketServer { onLifecycleIntent: (intent) => { this.onLifecycleIntent?.(intent); }, + subagentAuthority: () => { + if (!connection) throw new Error("Session admission is not ready"); + if (connection.hubExecutionAgents) + throw Object.assign( + new Error("Execution-scoped subagent relationships are not supported"), + { code: "unsupported_capability" }, + ); + return { + principalId: connection.principalId, + sourceId: connection.session.getHostSourceId(), + permissionCeiling: connection.session.getPermissions(), + }; + }, hubExecutionAgents: admission.hubExecutionAgents, hubRelationships: this.hubRelationships ?? undefined, }); @@ -1561,6 +1620,8 @@ export class VoiceAssistantWebSocketServer { private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + subagents: this.subagents, + subagentAuthority: options.subagentAuthority, connectedHosts: this.connectedHosts, getConnectedHostBufferedAmount: () => { let total = 0; @@ -1935,6 +1996,13 @@ export class VoiceAssistantWebSocketServer { pluginId?: string, ): void { if (pluginId) return; + for (const socket of connection.sockets) { + const ownedSource = this.ownedSocketSources.get(socket); + if (ownedSource) { + connection.session.setHostSource(ownedSource); + return; + } + } const producer = hello.producer; const sourceId = hello.routed && producer @@ -1957,6 +2025,7 @@ export class VoiceAssistantWebSocketServer { ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { connectedHosts: true, + fleetSubagents: true, hostMultiplexing: true, usageSources: true, ownedSubscriptions: true,