From 702bc942b7c37363068ec06d7616024da115dded Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 12:15:51 +0200 Subject: [PATCH] fix(agents): retain creation identity and dispatch evidence across hosts --- packages/client/src/agents/runs.ts | 23 + packages/client/src/daemon-client.ts | 96 ++- packages/client/src/index.test.ts | 2 + packages/client/src/index.ts | 118 ++- packages/client/src/schedules/index.ts | 36 + packages/protocol/src/agent-dispatch.ts | 14 + packages/protocol/src/agent-message.ts | 10 + packages/protocol/src/agent-runs.ts | 12 +- packages/protocol/src/messages.ts | 81 +- .../src/paseo-tool-presentation/catalog.ts | 1 + .../src/server/agent/agent-manager.test.ts | 140 ++- .../server/src/server/agent/agent-manager.ts | 66 +- .../src/server/agent/agent-messages/index.ts | 25 +- .../server/src/server/agent/agent-prompt.ts | 16 +- .../src/server/agent/mcp-parity.e2e.test.ts | 300 ++++++- .../src/server/agent/mcp-server.test.ts | 250 +----- .../server/src/server/agent/runs/index.ts | 172 +++- .../src/server/agent/tools/host-tool.ts | 68 ++ .../src/server/agent/tools/paseo-tools.ts | 261 +++--- .../authorization/operation-permissions.ts | 7 +- packages/server/src/server/bootstrap.ts | 3 +- .../server/connected-hosts/hosts.e2e.test.ts | 804 ++++++++++++++++-- .../server/src/server/creation/index.test.ts | 8 +- packages/server/src/server/creation/index.ts | 70 +- .../src/server/message-receipts/index.test.ts | 20 + .../src/server/message-receipts/index.ts | 41 +- packages/server/src/server/session.ts | 353 ++++++-- .../src/server/session.workspaces.test.ts | 59 +- packages/server/src/server/subagents/index.ts | 54 +- .../server/test-utils/fake-agent-client.ts | 19 + .../websocket-server.browser-tools.test.ts | 1 + .../websocket-server.notifications.test.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 1 + ...cket-server.terminal-notifications.test.ts | 1 + .../server/src/server/websocket-server.ts | 4 +- 35 files changed, 2411 insertions(+), 726 deletions(-) create mode 100644 packages/client/src/agents/runs.ts create mode 100644 packages/client/src/schedules/index.ts create mode 100644 packages/protocol/src/agent-dispatch.ts create mode 100644 packages/server/src/server/agent/tools/host-tool.ts diff --git a/packages/client/src/agents/runs.ts b/packages/client/src/agents/runs.ts new file mode 100644 index 0000000000..cdc08f9a57 --- /dev/null +++ b/packages/client/src/agents/runs.ts @@ -0,0 +1,23 @@ +import type { DaemonClient } from "../daemon-client.js"; + +/** Presents atomic current run evidence and subsequent checkpoints through one snapshot callback. */ +export function observeAgentRun(client: DaemonClient, agentId: string, submissionId: string) { + const source = client.observeAgentRun(agentId, submissionId); + return { + get subscriptionId() { + return source.subscriptionId; + }, + ready: source.ready, + release: () => source.release(), + subscribe(observer: Parameters[0]) { + return source.subscribe({ + snapshot: observer.snapshot, + update(message) { + if (message.type === "agent.runs.changed") observer.snapshot(message.payload); + else observer.update(message); + }, + error: observer.error, + }); + }, + }; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index c722fc0243..2edcf98ad0 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,5 @@ +import type { AgentMessageSource } from "@getpaseo/protocol/agent-message"; +import type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch"; import { HOST_ROUTE_OPCODE, encodeHostRouteFrame, @@ -17,7 +19,7 @@ import { } from "./connection/index.js"; import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; -import type { z } from "zod"; +import { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; @@ -434,6 +436,9 @@ export interface DaemonClientTrace { } export interface SendMessageOptions { + source?: AgentMessageSource; + /** Suppress delivery when the recipient is archived, instead of unarchiving. */ + unarchive?: boolean; sourceAgentId?: string; messageId?: string; /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ @@ -1685,23 +1690,42 @@ export class DaemonClient { code: "unsupported_capability", }); } - observeAgentCreation(idempotencyKey: string) { + observeCreation(input: { kind: "agent" | "workspace"; idempotencyKey: string }) { this.requireFleetSubagents(); return this.observe("creation.subscribe.response", { type: "creation.subscribe.request", - kind: "agent", - idempotencyKey, + ...input, }); } - async cancelAgentCreation(input: { idempotencyKey: string; agentId: string }) { + async closeAgentSession(agentId: string): Promise { + this.requireFleetSubagents(); + await this.sendCorrelatedSessionRequest({ + message: { type: "agent.close.request", agentId }, + responseType: "agent.close.response", + }); + } + async cancelCreation(creation: import("@getpaseo/protocol/messages").CreationCancelInput) { this.requireFleetSubagents(); return ( await this.sendCorrelatedSessionRequest({ - message: { type: "agent.create.cancel.request", ...input }, - responseType: "agent.create.cancel.response", + message: { type: "creation.cancel.request", creation }, + responseType: "creation.cancel.response", }) ).cancellation; } + observeAgentRun(agentId: string, submissionId: string) { + this.requireFleetSubagents(); + return this.owned.observe(async (accept) => { + const result = await this.sendCorrelatedSessionRequest({ + message: { type: "agent.runs.get.request", agentId, submissionId, observe: true }, + responseType: "agent.runs.get.response", + }); + if (!result.subscriptionId) throw new Error("Host omitted run subscription identity"); + const snapshot = { run: result.run, subscriptionId: result.subscriptionId }; + accept(snapshot); + return snapshot; + }); + } async getAgentRun(agentId: string, submissionId: string) { this.requireFleetSubagents(); return ( @@ -3783,17 +3807,22 @@ export class DaemonClient { // Agent Interaction // ============================================================================ - async sendAgentMessage( - agentId: string, - text: string, - options?: SendMessageOptions, - ): Promise { + private assertMessageSendSupported(options: SendMessageOptions | undefined): void { if ( options?.sourceAgentId && this.lastServerInfoMessage?.features?.agentMessageProvenance !== true ) { throw new Error("Update the Paseo host to send messages with agent provenance."); } + if (options?.unarchive !== undefined || options?.source) this.requireFleetSubagents(); + } + + async sendAgentMessageWithReceipt( + agentId: string, + text: string, + options?: SendMessageOptions, + ): Promise { + this.assertMessageSendSupported(options); const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -3802,10 +3831,12 @@ export class DaemonClient { agentId, text, sourceAgentId: options?.sourceAgentId, - ...(messageId ? { messageId } : {}), - ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), - ...(options?.images ? { images: options.images } : {}), - ...(options?.attachments ? { attachments: options.attachments } : {}), + source: options?.source, + unarchive: options?.unarchive, + messageId, + activeTurnBehavior: options?.activeTurnBehavior, + images: options?.images, + attachments: options?.attachments, }); const payload = await this.sendRequest({ requestId, @@ -3822,8 +3853,26 @@ export class DaemonClient { }, }); if (!payload.accepted) { - throw new Error(payload.error ?? "sendAgentMessage rejected"); + throw Object.assign(new Error(payload.error ?? "sendAgentMessage rejected"), { + code: payload.code, + outcome: payload.outcome, + }); } + // COMPAT(message-dispatch-evidence): added in v0.12, remove after 2027-04-20. + // Older hosts and completed receipts have no evidence of their dispatch disposition. + return { + submissionId: messageId, + disposition: payload.dispatch?.disposition ?? "unknown", + tracking: payload.dispatch?.tracking ?? "unavailable", + }; + } + + async sendAgentMessage( + agentId: string, + text: string, + options?: SendMessageOptions, + ): Promise { + await this.sendAgentMessageWithReceipt(agentId, text, options); } async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise { @@ -3864,6 +3913,14 @@ export class DaemonClient { } async cancelAgent(agentId: string): Promise { + await this.requestAgentCancellation(agentId); + } + async cancelAgentWithStatus(agentId: string): Promise<{ cancelled: boolean }> { + this.requireFleetSubagents(); + const result = await this.requestAgentCancellation(agentId); + return z.object({ cancelled: z.boolean() }).parse(result); + } + private async requestAgentCancellation(agentId: string) { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "cancel_agent_request", @@ -3884,9 +3941,8 @@ export class DaemonClient { return msg.payload; }, }); - if (payload.error) { - throw new Error(payload.error); - } + if (payload.error) throw new Error(payload.error); + return payload; } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index e6b15d06bf..a0ed6e8dec 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -266,11 +266,13 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual([ "agents", "config", + "creations", "dispose", "hosts", "observeEvents", "projects", "providers", + "schedules", "terminals", "workspaces", ]); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ce744fb695..ba03a2f63e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,8 @@ +import { observeAgentRun } from "./agents/runs.js"; +import type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch"; +export type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch"; +import { createScheduleActions, type PaseoScheduleActions } from "./schedules/index.js"; +export type { PaseoScheduleActions } from "./schedules/index.js"; import { observeAgent } from "./agents/observation.js"; import { safeRandomId } from "./daemon-client-transport-utils.js"; import { createHostActions, type PaseoHostActions } from "./hosts/index.js"; @@ -188,6 +193,14 @@ export interface PaseoWorkspaceHandle { readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise; }; + readonly scripts: { + list(requestId?: string): ReturnType; + start( + scriptName: string, + requestId?: string, + ): ReturnType; + stop(scriptName: string, requestId?: string): ReturnType; + }; readonly terminals: PaseoWorkspaceTerminalActions; current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; @@ -238,7 +251,7 @@ export type PaseoAgentProvider = PaseoAgentSessionConfig["provider"]; export type PaseoProviderFeatureValues = Record; export interface PaseoAgentConfig { - /** Provider and model in `provider/model` format. */ + /** Provider ID, or a provider/model pair when inspecting a selected model. */ provider: string; modeId?: PaseoAgentSessionConfig["modeId"]; thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"]; @@ -273,7 +286,9 @@ export interface PaseoAgentCreateOptions { labels?: Record; } -export type PaseoWorkspaceAgentCreateOptions = Omit; +export type PaseoWorkspaceAgentCreateOptions = Omit & { + cwd?: string; +}; export interface PaseoAgentRefetchResult { agent: PaseoAgent; @@ -351,6 +366,7 @@ export interface PaseoAgentTimelineHandle { export type PaseoSubagentCreateOptions = Omit & { serverId?: string; workspaceId?: string; + workspace?: import("@getpaseo/protocol/messages").SubagentCreateInput["workspace"]; cwd?: string; }; export interface PaseoSubagentActions { @@ -365,6 +381,7 @@ export type { AgentRunRecord, AgentRunCheckpoint } from "@getpaseo/protocol/agen export interface PaseoAgentHandle { observe(): OwnedSubscription; readonly runs: { + observe(submissionId: string): ReturnType; get( submissionId: string, ): Promise; @@ -395,7 +412,7 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; - send(text: string, options?: PaseoAgentSendOptions): Promise; + send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise; @@ -409,6 +426,13 @@ export interface PaseoAgentHandle { * rejecting. */ commands(options?: PaseoAgentCommandsOptions): Promise; + cancel(): ReturnType; + close(): Promise; + update(input: Parameters[1]): Promise; + setMode(modeId: string): ReturnType; + configure( + config: Parameters[1], + ): ReturnType; archive(): Promise<{ archivedAt: string }>; detach(): Promise; subscribe(handler: (update: PaseoAgentUpdate) => void): () => void; @@ -517,16 +541,19 @@ export interface PaseoConfigActions { } export interface PaseoCreationActions { - observeAgent(idempotencyKey: string): ReturnType; - /** Cancel only pre-admission creation; admitted work retains its existing receipt. */ - cancelAgent(input: { + observe(input: { + kind: "agent" | "workspace"; idempotencyKey: string; - agentId: string; - }): Promise; + }): ReturnType; + /** Fences pre-admission creation; admitted work retains its original receipt. */ + cancel( + input: import("@getpaseo/protocol/messages").CreationCancelInput, + ): Promise; } export interface PaseoApi { readonly creations: PaseoCreationActions; + readonly schedules: PaseoScheduleActions; readonly hosts: PaseoHostActions; dispose(): Promise; observeEvents: DaemonClient["observeEvents"]; @@ -641,6 +668,7 @@ export function createPaseoApi( (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), lifetime.signal, (agentId) => own(() => observeAgent(daemonClient, agentId)), + (agentId, submissionId) => own(() => observeAgentRun(daemonClient, agentId, submissionId)), (parentId) => ({ detach: (child) => { if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); @@ -655,7 +683,7 @@ export function createPaseoApi( return daemonClient.listSubagents(parentId); }, observe: () => own(() => daemonClient.observeSubagents(parentId)), - create: async ({ serverId, idempotencyKey, ...input }) => { + create: async ({ serverId, idempotencyKey, workspace, ...input }) => { if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); const normalized = toDaemonAgentCreateOptions( { ...input, cwd: input.cwd ?? "" }, @@ -664,6 +692,7 @@ export function createPaseoApi( const child = await daemonClient.createSubagent(parentId, { serverId: serverId ?? requireServerIdentity(daemonClient.getServerId()), idempotencyKey: idempotencyKey ?? safeRandomId(), + workspace, input: { ...normalized, labels: input.labels ?? {}, config: normalized.config! }, }); subagentRuntime ??= hosts.runtime(); @@ -722,6 +751,7 @@ export function createPaseoApi( createAgent, terminals, listenWorkspaces, + lifetime.signal, ); let disposal: Promise | null = null; @@ -814,11 +844,12 @@ export function createPaseoApi( return { dispose, + schedules: createScheduleActions(daemonClient, lifetime.signal), creations: { - observeAgent: (key) => own(() => daemonClient.observeAgentCreation(key)), - cancelAgent: (input) => { + observe: (input) => own(() => daemonClient.observeCreation(input)), + cancel: (input) => { if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); - return daemonClient.cancelAgentCreation(input); + return daemonClient.cancelCreation(input); }, }, hosts, @@ -866,8 +897,11 @@ export function createPaseoApi( listModels: (provider, options) => daemonClient.listProviderModels(provider, options), listModes: (provider, options) => daemonClient.listProviderModes(provider, options), listFeatures: ({ provider: providerModel, ...draftConfig }, options) => { - const { provider, model } = parseProviderModel(providerModel); - return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options); + const { provider, model } = parseDraftProviderSelection(providerModel); + return daemonClient.listProviderFeatures( + { ...draftConfig, provider, ...(model ? { model } : {}) }, + options, + ); }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), @@ -906,9 +940,13 @@ function createWorkspaceHandleFactory( createAgent: CreateAgent, terminals: PaseoTerminalActions, listen: (handler: PaseoWorkspaceUpdateHandler) => () => void, + signal: AbortSignal, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; + const assertActive = () => { + if (signal.aborted) throw new Error("Paseo API is disposed"); + }; let current = typeof workspace === "string" ? null : workspace; const refresh = async (options?: { requestId?: string }) => { @@ -955,11 +993,25 @@ function createWorkspaceHandleFactory( throw new Error(`Workspace ${id} has no available directory`); } return createAgent( - { ...options, cwd: snapshot.workspaceDirectory }, - { workspaceId: id, cwd: snapshot.workspaceDirectory }, + { ...options, cwd: options.cwd ?? snapshot.workspaceDirectory }, + { workspaceId: id, cwd: options.cwd ?? snapshot.workspaceDirectory }, ); }, }, + scripts: { + list: async (requestId) => { + assertActive(); + return daemonClient.listWorkspaceScripts(id, requestId); + }, + start: async (scriptName, requestId) => { + assertActive(); + return daemonClient.startWorkspaceScriptWithStatus(id, scriptName, requestId); + }, + stop: async (scriptName, requestId) => { + assertActive(); + return daemonClient.stopWorkspaceScript(id, scriptName, requestId); + }, + }, terminals: { create: (options) => terminals.create({ ...options, workspaceId: id }), list: (options) => terminals.list({ ...options, workspaceId: id }), @@ -994,6 +1046,7 @@ function createAgentHandleFactory( subscribeTimeline: DaemonClient["subscribeAgentTimeline"], signal: AbortSignal, observeSnapshot: (agentId: string) => OwnedSubscription, + observeRun: DaemonClient["observeAgentRun"], subagents: (parentId: string) => PaseoSubagentActions, ): AgentHandleFactory { const assertActive = () => { @@ -1023,6 +1076,10 @@ function createAgentHandleFactory( return observation; }, runs: { + observe: (submissionId) => { + assertActive(); + return observeRun(id, submissionId); + }, get: (submissionId) => { assertActive(); return daemonClient.getAgentRun(id, submissionId); @@ -1120,7 +1177,7 @@ function createAgentHandleFactory( }, send: async (text, options) => { assertActive(); - await daemonClient.sendAgentMessage(id, text, options); + return daemonClient.sendAgentMessageWithReceipt(id, text, options); }, respondToPermission: async ({ requestId, response }) => { assertActive(); @@ -1155,6 +1212,26 @@ function createAgentHandleFactory( assertActive(); return daemonClient.listCommands({ agentId: id, ...options }); }, + cancel: async () => { + assertActive(); + return daemonClient.cancelAgentWithStatus(id); + }, + close: async () => { + assertActive(); + await daemonClient.closeAgentSession(id); + }, + update: async (input) => { + assertActive(); + await daemonClient.updateAgent(id, input); + }, + setMode: (modeId) => { + assertActive(); + return daemonClient.setAgentMode(id, modeId); + }, + configure: (config) => { + assertActive(); + return daemonClient.applyAgentConfig(id, config); + }, archive: async () => { assertActive(); const result = await daemonClient.archiveAgent(id); @@ -1217,6 +1294,13 @@ function requireServerIdentity(serverId: string | null): string { return serverId; } +function parseDraftProviderSelection(selection: string): { provider: string; model?: string } { + if (selection.includes("/")) return parseProviderModel(selection); + const provider = selection.trim(); + if (!provider) throw new Error("Provider ID is required"); + return { provider }; +} + function parseProviderModel(selection: string): { provider: string; model: string } { const separator = selection.indexOf("/"); if (separator <= 0 || separator === selection.length - 1) { diff --git a/packages/client/src/schedules/index.ts b/packages/client/src/schedules/index.ts new file mode 100644 index 0000000000..49df7d42b4 --- /dev/null +++ b/packages/client/src/schedules/index.ts @@ -0,0 +1,36 @@ +import type { DaemonClient } from "../daemon-client.js"; + +export interface PaseoScheduleActions { + create: DaemonClient["scheduleCreate"]; + list: DaemonClient["scheduleList"]; + inspect: DaemonClient["scheduleInspect"]; + update: DaemonClient["scheduleUpdate"]; + pause: DaemonClient["schedulePause"]; + resume: DaemonClient["scheduleResume"]; + delete: DaemonClient["scheduleDelete"]; + logs: DaemonClient["scheduleLogs"]; + runOnce: DaemonClient["scheduleRunOnce"]; +} + +export function createScheduleActions( + client: DaemonClient, + signal: AbortSignal, +): PaseoScheduleActions { + const action = + (method: (...args: A) => Promise) => + (...args: A): Promise => { + if (signal.aborted) return Promise.reject(new Error("Paseo API is disposed")); + return method(...args); + }; + return { + create: action(client.scheduleCreate.bind(client)), + list: action(client.scheduleList.bind(client)), + inspect: action(client.scheduleInspect.bind(client)), + update: action(client.scheduleUpdate.bind(client)), + pause: action(client.schedulePause.bind(client)), + resume: action(client.scheduleResume.bind(client)), + delete: action(client.scheduleDelete.bind(client)), + logs: action(client.scheduleLogs.bind(client)), + runOnce: action(client.scheduleRunOnce.bind(client)), + }; +} diff --git a/packages/protocol/src/agent-dispatch.ts b/packages/protocol/src/agent-dispatch.ts new file mode 100644 index 0000000000..fa3df71990 --- /dev/null +++ b/packages/protocol/src/agent-dispatch.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +/** Dispatch acknowledgment describes admission, never provider completion. */ +export const AgentDispatchSchema = z.object({ + disposition: z.enum(["turn_started", "steered", "out_of_band", "suppressed"]), + tracking: z.enum(["turn", "unavailable"]), +}); +export type AgentDispatch = z.infer; +export interface AgentSendResult { + submissionId: string; + // Older receipts/hosts did not record dispatch evidence. + disposition: AgentDispatch["disposition"] | "unknown"; + tracking: AgentDispatch["tracking"]; +} diff --git a/packages/protocol/src/agent-message.ts b/packages/protocol/src/agent-message.ts index 5caf63656d..62eba7c81e 100644 --- a/packages/protocol/src/agent-message.ts +++ b/packages/protocol/src/agent-message.ts @@ -1,3 +1,4 @@ +import { AgentAddressSchema } from "./addresses.js"; import { z } from "zod"; /** Semantic provenance, independent of provider envelopes and timeline presentation. */ @@ -5,9 +6,18 @@ export const AgentMessageSchema = z.object({ event: z.enum(["message", "finished", "errored", "permission-required", "closed"]), sender: z.object({ id: z.string().min(1), + address: AgentAddressSchema.optional(), title: z.string().min(1).optional(), }), text: z.string(), }); export type AgentMessage = z.infer; + +/** Declared message provenance; authenticated principal/source ownership is separate. */ +export const AgentMessageSourceSchema = z.object({ + agent: AgentAddressSchema, + title: z.string().min(1).optional(), + event: z.enum(["message", "finished", "errored", "permission-required", "closed"]), +}); +export type AgentMessageSource = z.infer; diff --git a/packages/protocol/src/agent-runs.ts b/packages/protocol/src/agent-runs.ts index 253d68e696..73385580ee 100644 --- a/packages/protocol/src/agent-runs.ts +++ b/packages/protocol/src/agent-runs.ts @@ -41,8 +41,18 @@ export const AgentRunGetRequestSchema = z.object({ requestId: z.string(), agentId: z.string(), submissionId: z.string(), + observe: z.boolean().optional(), }); export const AgentRunGetResponseSchema = z.object({ type: z.literal("agent.runs.get.response"), - payload: z.object({ requestId: z.string(), run: AgentRunRecordSchema.nullable() }), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + run: AgentRunRecordSchema.nullable(), + }), +}); + +export const AgentRunChangedSchema = z.object({ + type: z.literal("agent.runs.changed"), + payload: z.object({ subscriptionId: z.string(), run: AgentRunRecordSchema.nullable() }), }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5fa3306943..05160d97cd 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,6 +1,11 @@ -import { AgentRunGetRequestSchema, AgentRunGetResponseSchema } from "./agent-runs.js"; +import { AgentDispatchSchema } from "./agent-dispatch.js"; +import { + AgentRunGetRequestSchema, + AgentRunGetResponseSchema, + AgentRunChangedSchema, +} from "./agent-runs.js"; import { AgentAddressSchema, WorkspaceAddressSchema } from "./addresses.js"; -import { AgentMessageSchema } from "./agent-message.js"; +import { AgentMessageSchema, AgentMessageSourceSchema } from "./agent-message.js"; import { HostsListRequestSchema, HostsAddRequestSchema, @@ -1392,6 +1397,8 @@ export const SendAgentMessageRequestSchema = z.object({ text: z.string(), /** Opaque sender identity for agent-originated prompts. */ sourceAgentId: z.string().min(1).optional(), + source: AgentMessageSourceSchema.optional(), + unarchive: z.boolean().optional(), messageId: z.string().optional(), // Client-provided ID for deduplication activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), @@ -3255,6 +3262,12 @@ export type SubagentDirectory = z.infer; export const SubagentCreateInputSchema = z.object({ serverId: z.string().min(1), idempotencyKey: z.string().min(1).max(512), + workspace: WorkspaceCreateRequestSchema.omit({ + type: true, + requestId: true, + agent: true, + subscribe: true, + }).optional(), input: CreateAgentRequestMessageSchema.omit({ type: true, requestId: true, @@ -3312,15 +3325,39 @@ export const SubagentsChangedSchema = z.object({ payload: z.object({ subscriptionId: z.string(), directory: SubagentDirectorySchema }), }); -export const AgentCreationCancelRequestSchema = z.object({ - type: z.literal("agent.create.cancel.request"), +export const AgentCloseRequestSchema = z.object({ + type: z.literal("agent.close.request"), requestId: z.string(), - idempotencyKey: z.string().min(1), - agentId: z.string().uuid(), + agentId: z.string(), +}); +export const AgentCloseResponseSchema = z.object({ + type: z.literal("agent.close.response"), + payload: z.object({ requestId: z.string() }), +}); + +export const CreationCancelInputSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("agent"), + idempotencyKey: z.string().min(1), + agentId: z.string().uuid(), + }), + z.object({ + kind: z.literal("workspace"), + idempotencyKey: z.string().min(1), + workspaceId: z.string().regex(/^wks_[a-f0-9]{16}$/), + agentId: z.string().uuid(), + }), +]); +export type CreationCancelInput = z.infer; +export const CreationCancelRequestSchema = z.object({ + type: z.literal("creation.cancel.request"), + requestId: z.string(), + creation: CreationCancelInputSchema, }); export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ - AgentCreationCancelRequestSchema, + AgentCloseRequestSchema, + CreationCancelRequestSchema, AgentRunGetRequestSchema, SubagentsDetachRequestSchema, SubagentsForgetRequestSchema, @@ -5008,6 +5045,7 @@ export const CancelAgentResponseMessageSchema = z.object({ payload: z.object({ requestId: z.string(), agentId: z.string(), + cancelled: z.boolean().optional(), agent: AgentSnapshotPayloadSchema.nullable(), error: z.string().nullable().optional(), }), @@ -5044,18 +5082,26 @@ 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 }), +const CreationReservationSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("agent"), reservedAgent: AgentAddressSchema }), + z.object({ + kind: z.literal("workspace"), + reservedAgent: AgentAddressSchema, + reservedWorkspace: WorkspaceAddressSchema, + }), +]); +export const CreationCancellationSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("canceled"), reservation: CreationReservationSchema }), z.object({ status: z.literal("admitted"), - reservedAgent: AgentAddressSchema, + reservation: CreationReservationSchema, 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 CreationCancellation = z.infer; +export const CreationCancelResponseSchema = z.object({ + type: z.literal("creation.cancel.response"), + payload: z.object({ requestId: z.string(), cancellation: CreationCancellationSchema }), }); export type CreationSnapshot = z.infer; export type AgentCreateRequest = z.infer; @@ -5138,6 +5184,9 @@ export const SendAgentMessageResponseMessageSchema = z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), + dispatch: AgentDispatchSchema.optional(), + code: z.string().optional(), + outcome: z.literal("unknown").optional(), error: z.string().nullable(), }), }); @@ -7023,8 +7072,10 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ - AgentCreationCancelResponseSchema, + AgentCloseResponseSchema, + CreationCancelResponseSchema, AgentRunGetResponseSchema, + AgentRunChangedSchema, SubagentsDetachResponseSchema, SubagentsForgetResponseSchema, SubagentsCreateResponseSchema, diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 1706134523..f9aae6af9a 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -157,6 +157,7 @@ export const PASEO_TOOL_CATALOG = { context: targetAgent(), detail: { inputOrder: ["agentId", "limit"] }, }, + list_hosts: { label: "List hosts" }, list_agents: { label: "List agents", detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5a5c3a464e..5cc050c9ec 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -533,6 +533,7 @@ class TestAgentSession implements AgentSession { } class ResumeTrackingTestAgentClient extends TestAgentClient { + retryStartCount = 0; private readonly retryStarted = deferred(); override async resumeSession( @@ -540,7 +541,10 @@ class ResumeTrackingTestAgentClient extends TestAgentClient { config?: Partial, ): Promise { this.resumeOverrides.push(config); - const signalRetryStarted = () => this.retryStarted.resolve(); + const signalRetryStarted = () => { + this.retryStartCount++; + this.retryStarted.resolve(); + }; return new (class extends TestAgentSession { override async startTurn(): Promise<{ turnId: string }> { signalRetryStarted(); @@ -665,6 +669,7 @@ async function startAndSteerThroughManager( activeTurnBehavior: behavior, runOptions: { clientMessageId: "replacement-client" }, }); + await manager.waitForAgentRunStart(agent.id); return { manager, agentId: agent.id, workdir }; } @@ -962,6 +967,14 @@ test("orders an accepted steer before output emitted while acknowledgement is pe release.resolve(); await expect(steer).resolves.toEqual({ status: "accepted" }); await consume; + expect(await manager.getAgentRun(agent.id, "hello-client")).toMatchObject({ + submissionId: "hello-client", + turnId: "active-turn-1", + status: "completed", + checkpoints: expect.arrayContaining([ + expect.objectContaining({ kind: "completed", result: "terminal output" }), + ]), + }); const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => { return ( (row.item.type === "user_message" && row.item.text === "hello") || @@ -4033,6 +4046,7 @@ test("a prompt after provider replacement reopens the stale session", async () = const result = await manager.waitForAgentEvent(created.id); expect(result.status).toBe("idle"); expect(replacement.resumeOverrides).toHaveLength(1); + expect(replacement.retryStartCount).toBe(1); const timeline = manager.getTimeline(created.id); expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([ { text: "continue after reload", clientMessageId: "stale-session-retry" }, @@ -8630,7 +8644,7 @@ test("fires onAgentArchived for archived parent and cascaded children", async () registry: storage, logger, }); - manager.setAgentArchivedCallback((agentId) => { + manager.onAgentArchived((agentId) => { archivedIds.push(agentId); }); @@ -8664,7 +8678,7 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => { registry: storage, logger, }); - manager.setAgentArchivedCallback((agentId) => { + manager.onAgentArchived((agentId) => { archivedIds.push(agentId); }); @@ -12173,3 +12187,123 @@ test("run journal reload binds subsequent evidence to the original submission an await rm(directory, { recursive: true, force: true }); } }); + +test("all accepted submissions bound to one actual turn recover its evidence after restart", async () => { + const { AgentRuns } = await import("./runs/index.js"); + const { mkdtemp, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "paseo-run-aliases-")); + try { + const first = new AgentRuns({ serverId: "srv_original", directory }); + for (const submission of ["initial", "steer-one", "steer-two"]) { + await first.prepare("agent", submission); + await first.start("agent", submission, "actual-turn"); + } + const restarted = new AgentRuns({ serverId: "srv_original", directory }); + await restarted.capture( + "agent", + "actual-turn", + { type: "turn_completed", provider: "codex" }, + "shared answer", + ); + for (const submission of ["initial", "steer-one", "steer-two"]) { + expect(await restarted.get("agent", submission)).toMatchObject({ + submissionId: submission, + turnId: "actual-turn", + status: "completed", + checkpoints: expect.arrayContaining([ + expect.objectContaining({ kind: "completed", result: "shared answer" }), + ]), + }); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("run admission permits only owner-rejected identical input, never pending or restarted uncertainty", async () => { + const { AgentRuns } = await import("./runs/index.js"); + const { mkdtemp, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "run-admission-rejection-")); + try { + const journal = new AgentRuns({ serverId: "srv_test", directory }); + await journal.prepare("agent", "submission", { prompt: "original" }); + await expect( + journal.prepare("agent", "submission", { prompt: "original" }), + ).rejects.toMatchObject({ code: "submission_exists" }); + await expect( + journal.prepare("agent", "submission", { prompt: "different" }), + ).rejects.toMatchObject({ code: "submission_exists" }); + await journal.rejectUnadmitted("agent", "submission"); + await expect( + journal.prepare("agent", "submission", { prompt: "different" }), + ).rejects.toMatchObject({ code: "submission_exists" }); + await journal.prepare("agent", "submission", { prompt: "original" }); + const restarted = new AgentRuns({ serverId: "srv_test", directory }); + expect(await restarted.get("agent", "submission")).toMatchObject({ + status: "interrupted", + turnId: null, + }); + await expect( + restarted.prepare("agent", "submission", { prompt: "original" }), + ).rejects.toMatchObject({ code: "submission_exists" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a gated first admission excludes competing same-submission work through the manager", async () => { + const entered = deferred(); + const release = deferred(); + let admissions = 0; + class GatedAdmission extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + admissions++; + entered.resolve(); + await release.promise; + return super.startTurn(prompt); + } + } + const session = new GatedAdmission({ provider: "codex", cwd: process.cwd() }); + const manager = new AgentManager({ + serverId: "srv_test", + logger, + clients: { + codex: new (class extends TestAgentClient { + override async createSession() { + return session; + } + })(), + }, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: process.cwd() }, undefined, { + workspaceId: undefined, + }); + const consume = (async () => { + for await (const _event of manager.streamAgent(agent.id, "original", { + clientMessageId: "one-submission", + })) { + } + })(); + try { + await entered.promise; + for (const prompt of ["original", "different input"]) + expect(() => + manager.streamAgent(agent.id, prompt, { clientMessageId: "one-submission" }), + ).toThrow("already has an active run"); + expect(admissions).toBe(1); + release.resolve(); + await manager.waitForAgentRunStart(agent.id); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await consume; + expect(admissions).toBe(1); + expect(session.startPrompts).toEqual(["original"]); + expect(await manager.getAgentRun(agent.id, "one-submission")).toMatchObject({ + status: "completed", + turnId: "active-turn-1", + }); + } finally { + release.resolve(); + await manager.closeAgent(agent.id); + await consume.catch(() => {}); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2479ff6950..9b98e028f0 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -767,14 +767,14 @@ export class AgentManager { ) => ProviderPaseoToolsPolicy | undefined; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; - private onAgentArchived?: AgentArchivedCallback; + private readonly agentArchivedListeners = new Set(); private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; private logger: Logger; private readonly rescueTimeouts: Required; private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"]; private acceptingAgentRegistrations = true; - private readonly serverId: string; + readonly serverId: string; constructor(options: AgentManagerOptions) { this.serverId = options.serverId; @@ -866,12 +866,19 @@ export class AgentManager { this.onAgentAttention = callback; } + observeAgentRun(...args: Parameters) { + return this.runJournal.observe(...args); + } + getAgentRun(agentId: string, submissionId: string) { return this.runJournal.get(agentId, submissionId); } - setAgentArchivedCallback(callback: AgentArchivedCallback): void { - this.onAgentArchived = callback; + onAgentArchived(callback: AgentArchivedCallback): () => void { + this.agentArchivedListeners.add(callback); + return () => { + this.agentArchivedListeners.delete(callback); + }; } setMcpBaseUrl(url: string | null): void { @@ -1988,14 +1995,14 @@ export class AgentManager { } private async fireAgentArchived(agentId: string): Promise { - const callback = this.onAgentArchived; - if (!callback) { - return; - } - try { - await callback(agentId); - } catch (error) { - this.logger.warn({ err: error, agentId }, "onAgentArchived callback failed"); + const results = await Promise.allSettled( + [...this.agentArchivedListeners].map(async (listener) => { + await listener(agentId); + }), + ); + for (const result of results) { + if (result.status === "rejected") + this.logger.warn({ err: result.reason, agentId }, "Agent archive listener failed"); } } @@ -2572,7 +2579,7 @@ export class AgentManager { const { agent, agentId, pendingRun, prompt, options } = params; try { const submissionId = options?.clientMessageId ?? pendingRun.token; - await this.runJournal.prepare(agentId, submissionId); + await this.runJournal.prepare(agentId, submissionId, { prompt, options }); const result = await agent.session.startTurn(prompt, options); try { await this.runJournal.start(agentId, submissionId, result.turnId); @@ -2593,6 +2600,14 @@ export class AgentManager { throw error; } if (isStaleProviderSessionError(error)) { + await this.runJournal + .rejectUnadmitted(agentId, options?.clientMessageId ?? pendingRun.token) + .catch((journalError) => { + this.logger.error( + { err: journalError, agentId }, + "Failed to persist definitive admission rejection", + ); + }); pendingRun.start = { status: "failed", error: error.message }; agent.pendingReplacement = false; if (!agent.activeForegroundTurnId) agent.lifecycle = "idle"; @@ -3009,6 +3024,12 @@ export class AgentManager { if (!clientMessageId) { return; } + await this.runJournal.acceptSteer(agent.id, clientMessageId, expectedTurnId).catch((error) => { + this.logger.error( + { err: error, agentId: agent.id, submissionId: clientMessageId }, + "Failed to persist accepted steer identity", + ); + }); this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, @@ -3559,6 +3580,9 @@ export class AgentManager { hasStarted = true; return; } + // A run selected for dispatch is still admitting even if the previous + // session's idle snapshot is replayed before provider admission settles. + if (this.runs.getPendingRun(agentId)?.start.status === "pending") return; if (!waitForActive || hasStarted) { if (terminalStatusOverride) { currentStatus = terminalStatusOverride; @@ -4378,6 +4402,15 @@ export class AgentManager { options?.fromHistory === true, terminalDisposition, ); + 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", + ); + }); + } const dispatchPromise = this.dispatchStreamEventByType({ agent, event, @@ -4392,13 +4425,6 @@ 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) { diff --git a/packages/server/src/server/agent/agent-messages/index.ts b/packages/server/src/server/agent/agent-messages/index.ts index 1543bf33b4..a65db60191 100644 --- a/packages/server/src/server/agent/agent-messages/index.ts +++ b/packages/server/src/server/agent/agent-messages/index.ts @@ -1,3 +1,4 @@ +import type { AgentMessageSource } from "@getpaseo/protocol/agent-message"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { randomUUID } from "node:crypto"; import { z } from "zod"; @@ -12,17 +13,29 @@ const sourceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("agent-message"), agentId: agentIdSchema, + serverId: z.string().min(1).optional(), title: z.string().min(1).optional(), }), z.object({ kind: z.literal("agent-notification"), agentId: agentIdSchema, + serverId: z.string().min(1).optional(), title: z.string().min(1).optional(), event: z.enum(["finished", "errored", "permission-required", "closed"]), }), ]); export type AgentPromptSource = z.infer; +export function fromMessageSource(source: AgentMessageSource): AgentPromptSource { + const identity = { + agentId: source.agent.agentId, + serverId: source.agent.serverId, + title: source.title, + }; + if (source.event === "message") return { ...identity, kind: "agent-message" }; + return { ...identity, kind: "agent-notification", event: source.event }; +} + interface AgentMessage { id: string; source: AgentPromptSource | null; @@ -69,9 +82,10 @@ export function formatAgentMessage(message: AgentMessage): string { const attributes = source ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` : 'kind="user-message"'; + const server = source?.serverId ? ` source-server-id="${escapeXml(source.serverId)}"` : ""; const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; - return `\n${escapeXml(message.text)}\n`; + return `\n${escapeXml(message.text)}\n`; } export function parseAgentMessage(text: string): AgentMessage | null { @@ -96,6 +110,7 @@ export function parseAgentMessage(text: string): AgentMessage | null { const source = sourceSchema.safeParse({ kind: attributes.get("kind"), agentId: attributes.get("source-agent-id"), + serverId: attributes.get("source-server-id"), event: attributes.get("event"), title: attributes.get("source-agent-title"), }); @@ -162,7 +177,13 @@ export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem callId: `paseo-agent-message:${message.id}`, agentMessage: { event: source.kind === "agent-message" ? "message" : source.event, - sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) }, + sender: { + id: source.agentId, + ...(source.serverId + ? { address: { serverId: source.serverId, agentId: source.agentId } } + : {}), + ...(source.title ? { title: source.title } : {}), + }, text: message.text, }, // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index bf6d50480b..0451770eb6 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -37,7 +37,7 @@ export interface StartAgentRunOptions { clearPendingPermissions?: boolean; } -export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started"; +export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started" | "suppressed"; async function steerOrReplaceActiveRun( agentManager: AgentRunController, @@ -289,14 +289,18 @@ export async function waitForAgentRunStartWithTimeout( async function resolvePromptSource( source: AgentPromptSource | undefined, - manager: Pick, + manager: Pick, storage: AgentStorage, ): Promise { if (!source) return undefined; + // COMPAT(local-message-source): added in v0.12, remove after 2027-04-20. + // Legacy callers identify a source on this daemon. Qualify once before dispatch. + const qualified = { ...source, serverId: source.serverId ?? manager.serverId }; + if (qualified.serverId !== manager.serverId) return qualified; const title = ( manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title )?.trim(); - return title ? { ...source, title } : source; + return title ? { ...qualified, title } : qualified; } /** @@ -307,8 +311,8 @@ async function resolvePromptSource( * chat mentions, notify-on-finish) MUST go through this so behavior can never * drift between them. * - * When `unarchive` is false and the agent is archived, the call is a silent - * no-op (returns the normal turn-start disposition) — the agent is not run. + * When `unarchive` is false and the agent is archived, dispatch is suppressed. + * No provider turn or run tracking record is created. */ export async function sendPromptToAgent( params: SendPromptToAgentParams, @@ -319,7 +323,7 @@ export async function sendPromptToAgent( let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { - return { disposition: "turn_started" }; + return { disposition: "suppressed" }; } if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { archivedAtToRestore = record.archivedAt; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index bfb6be7964..ca2fa1c041 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -158,7 +158,11 @@ const seededAgentProfile = { }; function createRecordingAgentClients(): Record { - const baseClients = createTestAgentClients(); + const baseClients = createTestAgentClients({ + beforeSetFeature: async (featureId) => { + if (featureId !== "test_feature") throw new Error(`Unsupported feature: ${featureId}`); + }, + }); launchConfigsByProvider = {}; const wrappedClients: Record = {}; @@ -609,6 +613,92 @@ describe("Suite A: Core Fixes", () => { } }); + test("MCP settings failure preserves metadata and no-op update preserves the full snapshot", async () => { + const agentId = await createTopLevelAgent(); + try { + await callToolStructured(topLevelClient, "update_agent", { + agentId, + name: "Configured metadata", + labels: { role: "worker" }, + settings: { + modeId: "default", + model: "claude-test-model", + features: { test_feature: true }, + }, + }); + const before = await callToolStructured(topLevelClient, "get_agent_status", { agentId }); + expect(before.snapshot).toMatchObject({ + id: agentId, + title: "Configured metadata", + model: "claude-test-model", + currentModeId: "default", + labels: { role: "worker" }, + capabilities: expect.any(Object), + availableModes: expect.any(Array), + pendingPermissions: [], + runtimeInfo: expect.any(Object), + persistence: expect.any(Object), + }); + expectAgentFeatureValue( + z.record(z.string(), z.unknown()).parse(before.snapshot), + "test_feature", + true, + ); + expect(await callToolStructured(topLevelClient, "update_agent", { agentId })).toMatchObject({ + success: true, + }); + expect( + (await callToolStructured(topLevelClient, "get_agent_status", { agentId })).snapshot, + ).toEqual(before.snapshot); + await expectToolError( + topLevelClient, + "update_agent", + { + agentId, + name: "Must not persist", + labels: { role: "changed" }, + settings: { features: { unsupported_feature: true } }, + }, + /feature/i, + ); + expect( + (await callToolStructured(topLevelClient, "get_agent_status", { agentId })).snapshot, + ).toEqual(before.snapshot); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("MCP reads archived history and hides internal stored agents", async () => { + const agentId = await createTopLevelAgent(); + await callToolStructured(topLevelClient, "archive_agent", { agentId }); + expect(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).toMatchObject( + { + status: "closed", + snapshot: { id: agentId, archivedAt: expect.any(String) }, + }, + ); + const now = new Date().toISOString(); + const internalId = crypto.randomUUID(); + await daemonHandle.daemon.agentStorage.upsert({ + id: internalId, + internal: true, + provider: "claude", + cwd: parentAgentCwd, + createdAt: now, + updatedAt: now, + labels: {}, + lastStatus: "closed", + title: "Internal history", + }); + await expectToolError( + topLevelClient, + "get_agent_status", + { agentId: internalId }, + /not found/i, + ); + }); + test("inspect_provider returns draft provider features over MCP", async () => { const payload = await callToolStructured(topLevelClient, "inspect_provider", { provider: "claude", @@ -1088,3 +1178,211 @@ describe("Suite E: Workspace Tools", () => { } }); }); + +test("MCP qualified control addresses the selected daemon with colliding agent IDs through one SDK workflow", async () => { + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const b = await createTestPaseoDaemon(); + const sourceClient = createHostClient(`127.0.0.1:${daemonHandle.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + const targetClient = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await Promise.all([sourceClient.connect(), targetClient.connect()]); + const source = createPaseoApi(sourceClient); + const target = createPaseoApi(targetClient); + try { + const agentId = crypto.randomUUID(); + await source.agents.create({ + agentId, + cwd: daemonHandle.staticDir, + title: "Origin identity", + config: { provider: "claude/claude-test-model" }, + }); + await target.agents.create({ + agentId, + cwd: b.staticDir, + title: "Destination identity", + config: { provider: "claude/claude-test-model" }, + }); + const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await expect.poll(async () => (await source.hosts.list()).hosts[0]?.status).toBe("online"); + const serverId = b.daemon.getServerId(); + const status = await callToolStructured(topLevelClient, "get_agent_status", { + serverId, + agentId, + }); + expect(status).toMatchObject({ + serverId, + snapshot: { id: agentId, title: "Destination identity" }, + }); + await callToolStructured(topLevelClient, "update_agent", { + serverId, + agentId, + name: "Changed through selected host", + }); + await callToolStructured(topLevelClient, "set_agent_mode", { + serverId, + agentId, + modeId: "default", + }); + expect((await target.agents.ref(agentId).refresh())?.agent).toMatchObject({ + title: "Changed through selected host", + currentModeId: "default", + }); + expect((await source.agents.ref(agentId).refresh())?.agent.title).toBe("Origin identity"); + expect( + await callToolStructured(topLevelClient, "cancel_agent", { serverId, agentId }), + ).toMatchObject({ serverId, success: false }); + expect( + await callToolStructured(topLevelClient, "kill_agent", { serverId, agentId }), + ).toMatchObject({ serverId, success: true }); + expect(await target.agents.ref(agentId).refresh()).not.toBeNull(); + expect((await source.agents.ref(agentId).refresh())?.agent.title).toBe("Origin identity"); + expect(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).toMatchObject( + { serverId: daemonHandle.daemon.getServerId(), snapshot: { title: "Origin identity" } }, + ); + await source.hosts.update(registration.registrationId, { enabled: false }); + const failure = await topLevelClient.callTool({ + name: "get_agent_status", + args: { serverId, agentId }, + }); + expect(failure.isError).toBe(true); + expect(getStructuredContent(failure)).toMatchObject({ + serverId, + code: "host_disabled", + error: expect.any(String), + }); + } finally { + await Promise.allSettled([source.dispose(), target.dispose()]); + await Promise.allSettled([sourceClient.close(), targetClient.close()]); + await b.close(); + } +}); + +test("MCP preserves selected destination and unknown submitted-action outcome at the client response", async () => { + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + let release!: () => void; + let reached!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + reached = resolve; + }); + let closes = 0; + const b = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + closeSession: async () => { + closes++; + reached(); + await gate; + }, + }), + }); + const sourceClient = createHostClient(`127.0.0.1:${daemonHandle.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + const targetClient = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await Promise.all([sourceClient.connect(), targetClient.connect()]); + const source = createPaseoApi(sourceClient); + const target = createPaseoApi(targetClient); + try { + const agent = await target.agents.create({ + cwd: b.staticDir, + title: "Uncertain close", + config: { provider: "codex/test" }, + }); + const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await expect + .poll( + async () => + (await source.hosts.list()).hosts.find( + (host) => host.registrationId === registration.registrationId, + )?.status, + ) + .toBe("online"); + const closing = topLevelClient.callTool({ + name: "kill_agent", + args: { serverId: agent.serverId, agentId: agent.id }, + }); + void closing.catch(() => {}); + await ready; + await source.hosts.update(registration.registrationId, { enabled: false }); + const failure = await closing; + expect(failure.isError).toBe(true); + expect(getStructuredContent(failure)).toMatchObject({ + serverId: agent.serverId, + code: "DAEMON_CONNECTION_LOST", + outcome: "unknown", + }); + release(); + await expect.poll(async () => (await agent.refresh())?.agent.status).toBe("closed"); + expect(closes).toBe(1); + } finally { + release(); + await Promise.allSettled([source.dispose(), target.dispose()]); + await Promise.allSettled([sourceClient.close(), targetClient.close()]); + await b.close(); + } +}); + +test("MCP provider discovery uses the selected host profiles and models", async () => { + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const profile = { ...seededAgentProfile, id: "destination-profile", name: "Destination work" }; + const b = await createTestPaseoDaemon({ agentProfiles: [profile] }); + const client = createHostClient(`127.0.0.1:${daemonHandle.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await client.connect(); + const source = createPaseoApi(client); + try { + const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await expect + .poll( + async () => + (await source.hosts.list()).hosts.find( + (host) => host.registrationId === registration.registrationId, + )?.status, + ) + .toBe("online"); + const serverId = b.daemon.getServerId(); + expect(await callToolStructured(topLevelClient, "list_profiles", { serverId })).toMatchObject({ + serverId, + profiles: [profile], + }); + expect(await callToolStructured(topLevelClient, "list_providers", { serverId })).toMatchObject({ + serverId, + providers: expect.arrayContaining([expect.objectContaining({ id: "codex" })]), + }); + expect( + await callToolStructured(topLevelClient, "list_models", { serverId, provider: "codex" }), + ).toMatchObject({ serverId, provider: "codex", models: expect.any(Array) }); + const inspected = await callToolStructured(topLevelClient, "inspect_provider", { + serverId, + provider: "codex", + cwd: b.staticDir, + }); + expect(inspected.error).toBeUndefined(); + expect(inspected).toMatchObject({ + serverId, + provider: "codex", + enabled: true, + features: expect.any(Array), + }); + } finally { + await source.dispose(); + await client.close(); + await b.close(); + } +}); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index febd246562..21d0ef995c 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -33,7 +33,6 @@ import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.j import { AgentListItemPayloadSchema, AgentPermissionRequestPayloadSchema, - AgentSnapshotPayloadSchema, } from "@getpaseo/protocol/messages"; import { createPersistedProjectRecord, @@ -5321,87 +5320,6 @@ describe("update_agent MCP tool", () => { expect(lookupTool(server, "set_agent_feature")).toBeUndefined(); }); - - it("updates runtime settings before metadata", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "update_agent"); - const input = { - agentId: "agent-1", - name: "Updated agent", - labels: { role: "worker" }, - settings: { - modeId: "full-access", - model: "gpt-5.4", - thinkingOptionId: "high", - features: { fast_mode: true }, - }, - }; - - const parsed = await tool.inputSchema.safeParseAsync(input); - expect(parsed.success).toBe(true); - - const response = await tool.handler(input); - - expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access"); - expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4"); - expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high"); - expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true); - expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", { - title: "Updated agent", - labels: { role: "worker" }, - }); - expect(response.structuredContent).toEqual({ success: true }); - }); - - it("reports success for a no-op update with neither metadata nor settings", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "update_agent"); - - const response = await tool.handler({ agentId: "agent-1" }); - - expect(response.structuredContent).toEqual({ success: true }); - expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled(); - expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled(); - expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled(); - expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled(); - expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled(); - }); - - it("does not update metadata when runtime settings fail", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature")); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "update_agent"); - - await expect( - tool.handler({ - agentId: "agent-1", - name: "Should not persist", - labels: { role: "worker" }, - settings: { features: { fast_mode: true } }, - }), - ).rejects.toThrow("unsupported feature"); - - expect(spies.agentStorage.get).not.toHaveBeenCalled(); - expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled(); - }); }); describe("rename_workspace MCP tool", () => { @@ -6779,6 +6697,7 @@ describe("agent snapshot MCP serialization", () => { requiresAttention: false, attentionReason: null, attentionTimestamp: null, + parent: null, labels: { role: "researcher" }, }, ], @@ -6791,172 +6710,6 @@ describe("agent snapshot MCP serialization", () => { expect(structured.agents[0]).not.toHaveProperty("pendingPermissions"); }); - it("returns archived agent snapshots from storage for get_agent_status", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const record = createStoredRecord({ - id: "archived-agent", - archivedAt: "2026-04-12T00:00:00.000Z", - }); - spies.agentManager.getAgent.mockReturnValue(null); - spies.agentStorage.get.mockResolvedValue(record); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - logger, - providerSnapshotManager: createClaudeOnlyManager(), - }); - const tool = registeredTool(server, "get_agent_status"); - const response = await tool.handler({ agentId: "archived-agent" }); - - expect(response.structuredContent).toEqual({ - status: "closed", - snapshot: expect.objectContaining({ - id: "archived-agent", - archivedAt: "2026-04-12T00:00:00.000Z", - title: "Stored agent", - status: "closed", - }), - }); - expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent"); - }); - - it("returns full-detail snapshots from get_agent_status", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentStorage.get.mockResolvedValue({ title: "Full detail agent" }); - spies.agentManager.getAgent.mockReturnValue( - createManagedAgent({ - id: "full-detail-agent", - provider: "codex", - cwd: "/tmp/full-detail", - config: { model: "gpt-5.4", thinkingOptionId: "high" }, - runtimeInfo: { - provider: "codex", - sessionId: "session-full", - model: "gpt-5.4", - thinkingOptionId: "xhigh", - modeId: "auto", - }, - currentModeId: "auto", - availableModes: [ - { - id: "auto", - label: "Auto", - description: "Default coding mode", - }, - ], - features: [ - { - type: "toggle", - id: "web-search", - label: "Web search", - value: true, - }, - ], - pendingPermissions: new Map(), - persistence: { - provider: "codex", - sessionId: "session-full", - }, - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsSessionListing: true, - supportsDynamicModes: false, - supportsMcpServers: true, - supportsReasoningStream: false, - supportsToolInvocations: true, - supportsNewProviderCapability: true, - }, - }), - ); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "get_agent_status"); - const response = await tool.handler({ agentId: "full-detail-agent" }); - const snapshot = z.record(z.string(), z.unknown()).parse(response.structuredContent.snapshot); - - const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot); - if (!parsed.success) { - throw new Error( - `get_agent_status response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`, - ); - } - expect(response.structuredContent.status).toBe("idle"); - expect(snapshot).toEqual( - expect.objectContaining({ - id: "full-detail-agent", - title: "Full detail agent", - provider: "codex", - model: "gpt-5.4", - thinkingOptionId: "high", - effectiveThinkingOptionId: "xhigh", - currentModeId: "auto", - runtimeInfo: { - provider: "codex", - sessionId: "session-full", - model: "gpt-5.4", - thinkingOptionId: "xhigh", - modeId: "auto", - }, - persistence: { - provider: "codex", - sessionId: "session-full", - }, - }), - ); - expect(snapshot.capabilities).toEqual( - expect.objectContaining({ - supportsMcpServers: true, - supportsToolInvocations: true, - }), - ); - expect(snapshot.availableModes).toEqual([ - { - id: "auto", - label: "Auto", - description: "Default coding mode", - }, - ]); - expect(snapshot.features).toEqual([ - { - type: "toggle", - id: "web-search", - label: "Web search", - value: true, - }, - ]); - expect(snapshot.pendingPermissions).toEqual([]); - }); - - it("does not expose internal stored agents from get_agent_status", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.getAgent.mockReturnValue(null); - spies.agentStorage.get.mockResolvedValue( - createStoredRecord({ - id: "internal-agent", - internal: true, - }), - ); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - logger, - providerSnapshotManager: createClaudeOnlyManager(), - }); - const tool = registeredTool(server, "get_agent_status"); - - await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow( - "Agent internal-agent not found", - ); - }); - it("defaults list_agents to caller cwd and excludes archived agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const now = new Date().toISOString(); @@ -7144,6 +6897,7 @@ describe("agent snapshot MCP serialization", () => { requiresAttention: false, attentionReason: null, attentionTimestamp: null, + parent: null, labels: {}, }); expect(item).not.toHaveProperty("features"); diff --git a/packages/server/src/server/agent/runs/index.ts b/packages/server/src/server/agent/runs/index.ts index 6a9c07c050..3168c42013 100644 --- a/packages/server/src/server/agent/runs/index.ts +++ b/packages/server/src/server/agent/runs/index.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -9,11 +10,50 @@ import { import type { AgentStreamEvent } from "../agent-sdk-types.js"; import { writeJsonFileAtomic } from "../../atomic-file.js"; +const StoredRunSchema = AgentRunRecordSchema.extend({ + admission: z.enum(["pending", "rejected", "accepted"]).optional(), + inputFingerprint: z.string().optional(), +}); +type StoredRun = z.infer; + /** 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 records = new Map(); + private readonly submissionsByTurn = new Map>(); private readonly loadedAgents = new Set(); + private readonly observers = new Map void>>(); + observe( + agentId: string, + submissionId: string, + snapshot: (record: AgentRunRecord | null) => void, + changed: (record: AgentRunRecord | null) => void, + ): Promise<() => void> { + return this.mutate(async () => { + const key = this.identity(agentId, submissionId); + const record = await this.read(agentId, submissionId); + const observers = this.observers.get(key) ?? new Set(); + observers.add(changed); + this.observers.set(key, observers); + try { + snapshot(record ? AgentRunRecordSchema.parse(record) : null); + } catch (error) { + observers.delete(changed); + if (!observers.size) this.observers.delete(key); + throw error; + } + return () => { + observers.delete(changed); + if (!observers.size) this.observers.delete(key); + }; + }); + } + private publish(agentId: string): void { + for (const record of this.records.values()) { + if (record.agent.agentId !== agentId) continue; + for (const changed of this.observers.get(this.identity(agentId, record.submissionId)) ?? []) + changed(AgentRunRecordSchema.parse(record)); + } + } private queue: Promise = Promise.resolve(); constructor(private readonly options: { serverId: string; directory?: string }) {} @@ -22,11 +62,11 @@ export class AgentRuns { } private async load(agentId: string): Promise { if (this.loadedAgents.has(agentId)) return; - let records: AgentRunRecord[] = []; + let records: StoredRun[] = []; if (this.options.directory) { try { const saved: unknown = JSON.parse(await readFile(this.path(agentId), "utf8")); - records = AgentRunRecordSchema.array().parse(saved); + records = StoredRunSchema.array().parse(saved); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } @@ -41,13 +81,13 @@ export class AgentRuns { }); this.records.set(this.identity(agentId, record.submissionId), record); if (record.turnId && !terminal(record)) - this.submissionsByTurn.set(this.identity(agentId, record.turnId), record.submissionId); + this.bind(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 { + 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; @@ -63,11 +103,16 @@ export class AgentRuns { 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); + try { + if (this.options.directory) await writeJsonFileAtomic(this.path(agentId), records); + for (const record of records) + this.records.set(this.identity(agentId, record.submissionId), record); + } finally { + // Publish accepted evidence with its current durability even when storage fails. + this.publish(agentId); + } } - private async write(record: AgentRunRecord, retainEvidence = false): Promise { + private async write(record: StoredRun, 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" }); @@ -98,12 +143,24 @@ export class AgentRuns { // read or checkpoint retries the same atomic collection, without replay. await this.persistAgent(agentId).catch(() => {}); } - return this.read(agentId, submissionId); + const record = await this.read(agentId, submissionId); + return record ? AgentRunRecordSchema.parse(record) : null; }); } - prepare(agentId: string, submissionId: string): Promise { + prepare(agentId: string, submissionId: string, input: unknown = null): Promise { return this.mutate(async () => { - if (await this.read(agentId, submissionId)) + const existing = await this.read(agentId, submissionId); + const inputFingerprint = createHash("sha256").update(JSON.stringify(input)).digest("hex"); + if ( + existing?.admission === "rejected" && + existing.status === "submitted" && + existing.turnId === null && + existing.inputFingerprint === inputFingerprint + ) { + await this.write({ ...existing, admission: "pending" }); + return; + } + if (existing) throw Object.assign( new Error( "This run submission already exists; inspect it before submitting different work", @@ -114,11 +171,27 @@ export class AgentRuns { agent: { serverId: this.options.serverId, agentId }, submissionId, turnId: null, + admission: "pending", + inputFingerprint, status: "submitted", checkpoints: [{ sequence: 1, kind: "submitted", at: new Date().toISOString() }], }); }); } + /** The admission owner calls this only for a definitive pre-admission rejection. */ + rejectUnadmitted(agentId: string, submissionId: string): Promise { + return this.mutate(async () => { + const record = await this.read(agentId, submissionId); + if ( + !record || + record.status !== "submitted" || + record.turnId !== null || + record.admission !== "pending" + ) + throw new Error("Cannot reject an admission that is no longer pending"); + await this.write({ ...record, admission: "rejected" }, true); + }); + } start(agentId: string, submissionId: string, turnId: string): Promise { return this.mutate(async () => { const record = await this.read(agentId, submissionId); @@ -126,11 +199,39 @@ export class AgentRuns { if (record.turnId !== null && record.turnId !== turnId) throw new Error("Run submission cannot bind to a different provider turn"); record.turnId = turnId; + record.admission = "accepted"; 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); + this.bind(agentId, turnId, submissionId); + await this.write(record, true); + }); + } + private bind(agentId: string, turnId: string, submissionId: string): void { + const key = this.identity(agentId, turnId); + const submissions = this.submissionsByTurn.get(key) ?? new Set(); + submissions.add(submissionId); + this.submissionsByTurn.set(key, submissions); + } + /** Called only after the provider accepted a steer, before staged events drain. */ + acceptSteer(agentId: string, submissionId: string, turnId: string): Promise { + return this.mutate(async () => { + const existing = await this.read(agentId, submissionId); + if (existing) { + if (existing.turnId !== turnId) + throw new Error("Run submission already belongs to another turn"); + return; + } + const record: AgentRunRecord = { + agent: { serverId: this.options.serverId, agentId }, + submissionId, + turnId, + status: "submitted", + checkpoints: [{ sequence: 1, kind: "submitted", at: new Date().toISOString() }], + }; + this.append(record, { kind: "running" }); + this.bind(agentId, turnId, submissionId); await this.write(record, true); }); } @@ -145,22 +246,37 @@ export class AgentRuns { 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, + const submissions = this.submissionsByTurn.get(this.identity(agentId, turnId)); + if (!submissions) return; + let changed = false; + for (const submissionId of submissions) { + const record = await this.read(agentId, submissionId); + if (!record || terminal(record)) continue; + if ( + checkpoint.permissionRequestId && + record.checkpoints.some( + (existing) => + existing.kind === checkpoint.kind && + existing.permissionRequestId === checkpoint.permissionRequestId, + ) ) + continue; + this.append(record, checkpoint); + this.records.set(this.identity(agentId, submissionId), { + ...record, + durability: "pending", + }); + changed = true; + } + if ( + [...submissions].every((id) => { + const record = this.records.get(this.identity(agentId, id)); + return !record || terminal(record); + }) ) - return; - this.append(record, checkpoint); - if (terminal(record)) this.submissionsByTurn.delete(this.identity(agentId, turnId)); - await this.write(record, true); + this.submissionsByTurn.delete(this.identity(agentId, turnId)); + // Retain evidence for every accepted submission even if this write fails. + if (changed) await this.persistAgent(agentId); }); } failAdmission(agentId: string, submissionId: string, error: string): Promise { diff --git a/packages/server/src/server/agent/tools/host-tool.ts b/packages/server/src/server/agent/tools/host-tool.ts new file mode 100644 index 0000000000..f919b08cc2 --- /dev/null +++ b/packages/server/src/server/agent/tools/host-tool.ts @@ -0,0 +1,68 @@ +import type { HostRuntime, PaseoApi } from "@getpaseo/client"; +import { z } from "zod"; +import type { PaseoToolResult } from "./types.js"; + +/** Resolves one ordinary API scope and preserves operation evidence through cleanup and MCP serialization. */ +export async function executeHostTool( + resolveRuntime: () => Promise, + requestedServerId: string | undefined, + execute: ( + api: PaseoApi, + identity: { serverId: string; originServerId: string }, + ) => Promise, +): Promise { + let serverId = requestedServerId; + try { + const runtime = await resolveRuntime(); + const originServerId = (await runtime.directory()).self.serverId; + serverId ??= originServerId; + const api = await runtime.client(serverId); + let result: PaseoToolResult; + let payload: Record; + try { + result = await execute(api, { serverId, originServerId }); + payload = z.record(z.string(), z.unknown()).parse(result.structuredContent); + } catch (error) { + // The action owns its outcome. Failed cleanup cannot replace evidence that + // a submitted operation has an unknown outcome. + await api.dispose().catch(() => {}); + throw error; + } + let cleanupError: ReturnType | undefined; + try { + await api.dispose(); + } catch (error) { + cleanupError = describeError(error); + } + return { + ...result, + structuredContent: { + ...payload, + serverId, + ...(cleanupError ? { scopeCleanupError: cleanupError } : {}), + }, + }; + } catch (error) { + return { + content: [], + isError: true, + structuredContent: { ...(serverId ? { serverId } : {}), ...describeError(error) }, + }; + } +} +function describeError(error: unknown): { + error: string; + code?: string; + outcome?: "unknown"; + cause?: string; +} { + const details = error instanceof Error ? error : new Error(String(error)); + return { + error: details.message, + ...("code" in details && typeof details.code === "string" ? { code: details.code } : {}), + ...("outcome" in details && details.outcome === "unknown" + ? { outcome: "unknown" as const } + : {}), + ...("cause" in details && typeof details.cause === "string" ? { cause: details.cause } : {}), + }; +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 30ac658f3e..cfdd32a72a 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,5 +1,6 @@ +import { executeHostTool } from "./host-tool.js"; import { AgentDirectory } from "../directory.js"; -import type { HostRuntime } from "@getpaseo/client"; +import type { HostRuntime, PaseoApi } from "@getpaseo/client"; import { HostDirectorySchema } from "@getpaseo/protocol/hosts"; import { stat } from "node:fs/promises"; import { z } from "zod"; @@ -77,13 +78,6 @@ import { waitForAgentRunStartWithTimeout, } from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; -import { - archiveAgentCommand, - cancelAgentRunCommand, - closeAgentCommand, - setAgentModeCommand, - updateAgentCommand, -} from "../lifecycle-command.js"; import type { ForgeService } from "../../../services/forge-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { @@ -661,7 +655,6 @@ function createToolCatalog( workspaceScripts, scheduleService, providerSnapshotManager, - daemonConfigStore, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -707,6 +700,45 @@ function createToolCatalog( handler: handler as PaseoToolDefinition["handler"], }); }; + const registerHostTool = ( + name: string, + config: Omit & { inputSchema: T }, + handler: ( + input: z.output>, + api: PaseoApi, + context: PaseoToolExecutionContext, + ) => Promise, + ) => { + registerTool( + name, + { + ...config, + inputSchema: { + ...config.inputSchema, + serverId: z + .string() + .min(1) + .optional() + .describe("Destination server ID from list_hosts. Omit for this daemon."), + }, + outputSchema: { ...config.outputSchema, serverId: z.string() }, + }, + async ({ serverId, ...input }, context) => { + if (!options.hostRuntime) throw new Error("Host runtime is not configured"); + return executeHostTool(options.hostRuntime, serverId, (api, identity) => { + const normalized = { ...input }; + if (identity.originServerId !== identity.serverId && callerContext?.lockedCwd) + throw Object.assign( + new PaseoToolInputError("This caller is restricted to its origin workspace"), + { code: "access_denied" }, + ); + if ("cwd" in config.inputSchema && identity.originServerId === identity.serverId) + normalized.cwd = resolveScopedCwd(normalized.cwd); + return handler(normalized as z.output>, api, context); + }); + }, + ); + }; const toCatalog = (): PaseoToolCatalog => ({ tools, getTool(name: string): PaseoToolDefinition | undefined { @@ -2096,7 +2128,7 @@ function createToolCatalog( }, ); - registerTool( + registerHostTool( "get_agent_status", { title: "Get agent status", @@ -2110,37 +2142,15 @@ function createToolCatalog( snapshot: AgentSnapshotPayloadSchema, }, }, - async ({ agentId }) => { - const snapshot = agentManager.getAgent(agentId); - if (snapshot) { - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, - childLogger, - ); - return { - content: [], - structuredContent: ensureValidJson({ - status: snapshot.lifecycle, - snapshot: structuredSnapshot, - }), - }; - } - - const record = await agentStorage.get(agentId); - if (!record || record.internal) { + async ({ agentId }, api) => { + const refreshed = await api.agents.ref(agentId).refresh(); + if (!refreshed || refreshed.agent.internal) throw new PaseoToolInputError(`Agent ${agentId} not found`); - } - - const structuredSnapshot = buildStoredAgentPayload( - record, - new Set(providerSnapshotManager.listRegisteredProviderIds()), - ); return { content: [], structuredContent: ensureValidJson({ - status: structuredSnapshot.status, - snapshot: structuredSnapshot, + status: refreshed.agent.status, + snapshot: refreshed.agent, }), }; }, @@ -2215,7 +2225,7 @@ function createToolCatalog( }, ); - registerTool( + registerHostTool( "cancel_agent", { title: "Cancel agent run", @@ -2227,19 +2237,13 @@ function createToolCatalog( success: z.boolean(), }, }, - async ({ agentId }) => { - const { cancelled } = await cancelAgentRunCommand( - { agentManager, logger: childLogger }, - agentId, - ); - return { - content: [], - structuredContent: ensureValidJson({ success: cancelled }), - }; + async ({ agentId }, api) => { + const { cancelled } = await api.agents.ref(agentId).cancel(); + return { content: [], structuredContent: { success: cancelled } }; }, ); - registerTool( + registerHostTool( "archive_agent", { title: "Archive agent", @@ -2252,23 +2256,13 @@ function createToolCatalog( success: z.boolean(), }, }, - async ({ agentId }) => { - await archiveAgentCommand( - { - agentManager, - agentStorage, - logger: childLogger, - }, - agentId, - ); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; + async ({ agentId }, api) => { + await api.agents.ref(agentId).archive(); + return { content: [], structuredContent: { success: true } }; }, ); - registerTool( + registerHostTool( "kill_agent", { title: "Kill agent", @@ -2280,16 +2274,13 @@ function createToolCatalog( success: z.boolean(), }, }, - async ({ agentId }) => { - await closeAgentCommand({ agentManager }, agentId); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; + async ({ agentId }, api) => { + await api.agents.ref(agentId).close(); + return { content: [], structuredContent: { success: true } }; }, ); - registerTool( + registerHostTool( "update_agent", { title: "Update agent", @@ -2306,28 +2297,17 @@ function createToolCatalog( success: z.boolean(), }, }, - async ({ agentId, name, labels, settings }) => { - if (settings?.modeId !== undefined) { - await agentManager.setAgentMode(agentId, settings.modeId); - } - if (settings?.model !== undefined) { - await agentManager.setAgentModel(agentId, settings.model); - } - if (settings?.thinkingOptionId !== undefined) { - await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); - } - if (settings?.features) { - for (const [featureId, value] of Object.entries(settings.features)) { - await agentManager.setAgentFeature(agentId, featureId, value); - } - } - - await updateAgentCommand({ agentManager }, { agentId, name, labels }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; + async ({ agentId, name, labels, settings }, api) => { + const agent = api.agents.ref(agentId); + if (settings) + await agent.configure({ + modeId: settings.modeId, + modelId: settings.model, + thinkingOptionId: settings.thinkingOptionId, + featureValues: settings.features, + }); + if (name !== undefined || labels !== undefined) await agent.update({ name, labels }); + return { content: [], structuredContent: { success: true } }; }, ); @@ -3040,7 +3020,7 @@ function createToolCatalog( }, ); - registerTool( + registerHostTool( "list_providers", { title: "List providers", @@ -3050,18 +3030,13 @@ function createToolCatalog( providers: z.array(ProviderSummarySchema), }, }, - async () => { - const providers = (await providerSnapshotManager.listProviders({ wait: true })).map( - toProviderSummary, - ); - return { - content: [], - structuredContent: ensureValidJson({ providers }), - }; + async (_input, api) => { + const providers = (await api.providers.waitForReady()).entries.map(toProviderSummary); + return { content: [], structuredContent: ensureValidJson({ providers }) }; }, ); - registerTool( + registerHostTool( "list_models", { title: "List models", @@ -3074,22 +3049,17 @@ function createToolCatalog( models: z.array(AgentModelSchema), }, }, - async ({ provider }) => { - const models = await providerSnapshotManager.listModels({ - provider, - wait: true, - }); + async ({ provider }, api) => { + const result = await api.providers.listModels(provider); + if (result.error) throw new PaseoToolInputError(result.error); return { content: [], - structuredContent: ensureValidJson({ - provider, - models, - }), + structuredContent: ensureValidJson({ provider, models: result.models ?? [] }), }; }, ); - registerTool( + registerHostTool( "list_profiles", { title: "List agent profiles", @@ -3104,16 +3074,13 @@ function createToolCatalog( profiles: z.array(AgentProfileSchema), }, }, - async () => { - const profiles = daemonConfigStore?.get().agentProfiles ?? []; - return { - content: [], - structuredContent: ensureValidJson({ profiles }), - }; + async (_input, api) => { + const profiles = (await api.config.get()).config.agentProfiles ?? []; + return { content: [], structuredContent: ensureValidJson({ profiles }) }; }, ); - registerTool( + registerHostTool( "inspect_provider", { title: "Inspect provider", @@ -3131,34 +3098,27 @@ function createToolCatalog( features: z.array(AgentFeatureSchema), }, }, - async ({ provider, cwd, settings }) => { - const resolvedProviderModel = resolveScheduleProviderAndModel({ - provider, - defaultProvider: provider, - }); - const providerId = resolvedProviderModel.provider; - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - const entry = await providerSnapshotManager.getProvider({ - cwd: resolvedCwd, - provider: providerId, - wait: true, - }); - const summary = toProviderSummary(entry); - if (!entry.enabled) { - throw new PaseoToolInputError(`Provider '${providerId}' is disabled`); - } - if (entry.status !== "ready") { + async ({ provider, cwd, settings }, api) => { + if (!cwd) throw new PaseoToolInputError("Choose a working directory on the destination host"); + const selection = resolveScheduleProviderAndModel({ provider, defaultProvider: provider }); + const providerId = selection.provider; + const entry = (await api.providers.waitForReady({ cwd })).entries.find( + (item) => item.provider === providerId, + ); + if (!entry) throw new PaseoToolInputError(`Provider '${providerId}' was not found`); + if (!entry.enabled) throw new PaseoToolInputError(`Provider '${providerId}' is disabled`); + if (entry.status !== "ready") throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`); - } - const selectedModel = settings?.model ?? resolvedProviderModel.model; - const features = await agentManager.listDraftFeatures({ - provider: providerId, - cwd: resolvedCwd, - ...(settings?.modeId ? { modeId: settings.modeId } : {}), - ...(selectedModel ? { model: selectedModel } : {}), - ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), - ...(settings?.features ? { featureValues: settings.features } : {}), + const selectedModel = settings?.model ?? selection.model; + const result = await api.providers.listFeatures({ + provider: selectedModel ? `${providerId}/${selectedModel}` : providerId, + cwd, + modeId: settings?.modeId, + thinkingOptionId: settings?.thinkingOptionId, + featureValues: settings?.features, }); + if (result.error) throw new PaseoToolInputError(result.error); + const summary = toProviderSummary(entry); return { content: [], structuredContent: ensureValidJson({ @@ -3169,7 +3129,7 @@ function createToolCatalog( status: summary.status, modes: summary.modes, selectedModel: selectedModel ?? null, - features, + features: result.features ?? [], }), }; }, @@ -3231,7 +3191,7 @@ function createToolCatalog( }, ); - registerTool( + registerHostTool( "set_agent_mode", { title: "Set agent session mode", @@ -3246,12 +3206,9 @@ function createToolCatalog( newMode: z.string(), }, }, - async ({ agentId, modeId }) => { - const result = await setAgentModeCommand({ agentManager }, { agentId, modeId }); - return { - content: [], - structuredContent: ensureValidJson({ success: true, newMode: result.modeId }), - }; + async ({ agentId, modeId }, api) => { + await api.agents.ref(agentId).setMode(modeId); + return { content: [], structuredContent: { success: true, newMode: modeId } }; }, ); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 4cfcad4473..6a73244a0f 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -6,7 +6,8 @@ type OutboundOperation = SessionOutboundMessage["type"]; export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null; const INBOUND_PERMISSION = { - "agent.create.cancel.request": "workspace.write", + "agent.close.request": "workspace.write", + "creation.cancel.request": "workspace.write", "agent.runs.get.request": "workspace.read", "agent.subagents.detach.request": "workspace.write", "agent.subagents.forget.request": "workspace.write", @@ -230,8 +231,10 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { - "agent.create.cancel.response": "workspace.write", + "agent.close.response": "workspace.write", + "creation.cancel.response": "workspace.write", "agent.runs.get.response": "workspace.read", + "agent.runs.changed": "workspace.read", "agent.subagents.detach.response": "workspace.write", "agent.subagents.forget.response": "workspace.write", "agent.subagents.create.response": "workspace.write", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2f4e59fc30..80fa80ba0a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1377,7 +1377,7 @@ export async function createPaseoDaemon( archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); - agentManager.setAgentArchivedCallback(async (agentId) => { + const stopScheduleAgentArchived = agentManager.onAgentArchived(async (agentId) => { try { await scheduleService.completeForAgent(agentId); } catch (error) { @@ -1866,6 +1866,7 @@ export async function createPaseoDaemon( await pluginRuntime.stopAllPlugins(); terminalManager.killAll(); await speechService.stop(); + stopScheduleAgentArchived(); await scheduleService.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined); if (wsServer) { 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 c567db1930..d2dc734438 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1174,7 +1174,7 @@ test("channel output accounts for escaped text, awaits physical drain, and prese expect(wss.clients.size).toBe(1); }); -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, renameSync, writeFileSync } 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"))( @@ -2253,12 +2253,15 @@ test("creation cancellation survives destination restart and fences delayed subm const origin = await api(first); const agentId = crypto.randomUUID(); const idempotencyKey = "reserved-before-admission"; - expect(await origin.paseo.creations.cancelAgent({ agentId, idempotencyKey })).toEqual({ + expect(await origin.paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey })).toEqual({ status: "canceled", - reservedAgent: { serverId: first.daemon.getServerId(), agentId }, + reservation: { + kind: "agent", + reservedAgent: { serverId: first.daemon.getServerId(), agentId }, + }, }); await expect( - origin.paseo.creations.cancelAgent({ agentId: crypto.randomUUID(), idempotencyKey }), + origin.paseo.creations.cancel({ kind: "agent", agentId: crypto.randomUUID(), idempotencyKey }), ).rejects.toMatchObject({ code: "agent_request_key_conflict" }); const viewer = createHostClient(`127.0.0.1:${first.port}`, { clientId: crypto.randomUUID(), @@ -2270,7 +2273,8 @@ test("creation cancellation survives destination restart and fences delayed subm const viewerApi = createPaseoApi(viewer); cleanup.push(() => viewerApi.dispose()); await expect( - viewerApi.creations.cancelAgent({ + viewerApi.creations.cancel({ + kind: "agent", agentId: crypto.randomUUID(), idempotencyKey: "unauthorized-cancellation", }), @@ -2297,7 +2301,9 @@ test("creation cancellation survives destination restart and fences delayed subm }), ).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({ + expect( + await next.paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey }), + ).toMatchObject({ status: "canceled", }); }); @@ -2339,68 +2345,99 @@ test("creation cancellation reports admitted work before provider resource readi }); void creating.catch(() => {}); await ready; - const cancellation = await paseo.creations.cancelAgent({ agentId, idempotencyKey }); + const cancellation = await paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey }); expect(cancellation).toMatchObject({ status: "admitted", - reservedAgent: { serverId: instance.daemon.getServerId(), agentId }, + reservation: { + kind: "agent", + 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({ + expect(await paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey })).toMatchObject({ status: "admitted", creation: { phase: "completed" }, }); expect(prompts).toBe(1); - const observation = paseo.creations.observeAgent(idempotencyKey); + const observation = paseo.creations.observe({ kind: "agent", 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, +test.each(["agent", "workspace"] as const)( + "parent archive cancels an unadmitted %s creation after origin restart without stray resources", + async (kind) => { + 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" }, - 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); -}); + }); + 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(), + ...(kind === "workspace" + ? { + workspace: { source: { kind: "directory" as const, path: b.staticDir } }, + cwd: b.staticDir, + } + : { 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(); + if (kind === "workspace") { + const delayed = await destination.client.createWorkspace({ + idempotencyKey: `subagent:${row.id}`, + workspaceId: row.workspace!.workspaceId, + source: { kind: "directory", path: b.staticDir }, + agent: { + agentId: row.child.agentId, + config: { provider: "codex", cwd: b.staticDir }, + initialPrompt: "Must never be replayed", + }, + }); + expect(delayed).toMatchObject({ workspace: null, errorCode: "creation_canceled" }); + expect( + (await destination.paseo.workspaces.list()).entries.some( + (entry) => entry.id === row.workspace!.workspaceId, + ), + ).toBe(false); + } + 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-")); @@ -2525,3 +2562,666 @@ test("failed admitted creation exposes its failure through lifecycle recovery an expect((await parent.subagents.list()).rows).toEqual([]); expect(attempts).toBe(1); }); + +test("shared control and automation capabilities follow the same qualified workflow on every destination", async () => { + const a = await daemon(); + const b = await daemon(); + 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 agentId = crypto.randomUUID(); + for (const destination of [a, b]) { + const target = await runtime.client(destination.daemon.getServerId()); + cleanup.push(() => target.dispose()); + const agent = await target.agents.create({ + agentId, + cwd: destination.staticDir, + config: { provider: "codex/test", modeId: "default" }, + }); + await agent.update({ name: `Target ${agent.serverId}`, labels: { test: "qualified" } }); + await agent.setMode("default"); + expect((await agent.refresh())?.agent).toMatchObject({ + title: `Target ${agent.serverId}`, + labels: { test: "qualified" }, + currentModeId: "default", + }); + await agent.send('printf "ok" > sdk-control.txt', { messageId: "control-permission" }); + await expect + .poll(async () => (await agent.runs.get("control-permission"))?.status) + .toBe("permission"); + expect(await agent.cancel()).toEqual({ cancelled: true }); + expect(await agent.cancel()).toEqual({ cancelled: false }); + await expect + .poll(async () => (await agent.runs.get("control-permission"))?.status) + .toBe("canceled"); + const workspace = target.workspaces.ref((await agent.refresh())!.agent.workspaceId!); + expect((await workspace.scripts.list()).scripts).toEqual([]); + const created = await target.schedules.create({ + prompt: "Automation belongs to this destination", + cadence: { type: "cron", expression: "0 0 * * *" }, + target: { type: "agent", agentId }, + runOnCreate: false, + }); + expect(created.error).toBeNull(); + const id = created.schedule!.id; + expect((await target.schedules.inspect({ id })).schedule?.target).toEqual({ + type: "agent", + agentId, + }); + expect((await target.schedules.pause({ id })).schedule?.status).toBe("paused"); + expect((await target.schedules.resume({ id })).schedule?.status).toBe("active"); + await target.schedules.delete({ id }); + await agent.close(); + expect(await agent.refresh()).not.toBeNull(); + } +}); + +test("disposing a same-server scope fences captured workspace scripts while sibling and origin remain usable", async () => { + const instance = await daemon(); + await writeFile( + join(instance.staticDir, "paseo.json"), + JSON.stringify({ + scripts: { + fixture: { + type: "service", + command: `node -e "require('node:fs').writeFileSync('scope-effect.txt','started')"`, + }, + }, + }), + ); + const origin = await api(instance); + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: instance.staticDir }, + }); + const runtime = origin.paseo.hosts.runtime(); + cleanup.push(() => runtime.dispose()); + const scope = await runtime.client(instance.daemon.getServerId()); + const sibling = await runtime.client(instance.daemon.getServerId()); + cleanup.push(() => sibling.dispose()); + const captured = scope.workspaces.ref(workspace.id); + expect((await captured.scripts.list()).scripts.map((script) => script.scriptName)).toContain( + "fixture", + ); + await scope.dispose(); + await expect(Promise.resolve().then(() => captured.scripts.start("fixture"))).rejects.toThrow( + "disposed", + ); + await expect(Promise.resolve().then(() => captured.scripts.stop("fixture"))).rejects.toThrow( + "disposed", + ); + await expect(Promise.resolve().then(() => captured.scripts.list())).rejects.toThrow("disposed"); + expect( + (await sibling.workspaces.ref(workspace.id).scripts.list()).scripts.map( + (script) => script.scriptName, + ), + ).toContain("fixture"); + expect( + (await origin.paseo.workspaces.ref(workspace.id).scripts.list()).scripts.map( + (script) => script.scriptName, + ), + ).toContain("fixture"); + await expect(stat(join(instance.staticDir, "scope-effect.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +test("ordinary sends suppress archived recipients on every destination without starting tracked work", async () => { + const a = await daemon(); + const b = await daemon(); + 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()); + for (const destination of [a, b]) { + const target = await runtime.client(destination.daemon.getServerId()); + cleanup.push(() => target.dispose()); + const agent = await target.agents.create({ + cwd: destination.staticDir, + config: { provider: "codex/test" }, + }); + await agent.archive(); + const dispatch = await agent.send("Do not wake this archived recipient", { + messageId: "suppressed-delivery", + unarchive: false, + activeTurnBehavior: "steer", + }); + expect((await agent.refresh())?.agent.archivedAt).toBeTruthy(); + expect(dispatch).toMatchObject({ + submissionId: "suppressed-delivery", + disposition: "suppressed", + tracking: "unavailable", + }); + expect(await agent.runs.get("suppressed-delivery")).toBeNull(); + expect( + await agent.send("Do not wake this archived recipient", { + messageId: "suppressed-delivery", + unarchive: false, + activeTurnBehavior: "steer", + }), + ).toEqual(dispatch); + } +}); + +test("accepted steering and out-of-band sends expose actual tracking on every destination", async () => { + let release!: () => void; + const terminal = new Promise((resolve) => { + release = resolve; + }); + cleanup.push(async () => release()); + let starts = 0; + let commands = 0; + const clients = () => + createTestAgentClients({ + onStartTurn: () => { + starts++; + }, + beforeTurnComplete: () => terminal, + steerActiveTurn: async () => ({ status: "accepted" }), + tryHandleOutOfBand: (prompt) => + prompt === "/side-effect" + ? { + run: async () => { + commands++; + }, + } + : null, + }); + const a = await createTestPaseoDaemon({ agentClients: clients() }); + const b = await createTestPaseoDaemon({ agentClients: clients() }); + // Shutdown must not wait on a deliberately held provider completion. + cleanup.push(() => a.close()); + 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 agents = []; + try { + for (const destination of [a, b]) { + const target = await runtime.client(destination.daemon.getServerId()); + cleanup.push(() => target.dispose()); + const agent = await target.agents.create({ + cwd: destination.staticDir, + config: { provider: "codex/test" }, + }); + agents.push(agent); + const checkpoints: string[] = []; + const observing = agent.runs.observe("accepted-steer"); + cleanup.push(() => observing.release()); + observing.subscribe({ + snapshot: ({ run }) => { + if (run) checkpoints.push(run.status); + }, + update: () => {}, + }); + expect((await observing.ready).run).toBeNull(); + expect(await agent.send("Reply with initial output", { messageId: "initial" })).toMatchObject( + { disposition: "turn_started", tracking: "turn" }, + ); + expect( + await agent.send("Continue the existing turn", { + messageId: "accepted-steer", + activeTurnBehavior: "steer", + }), + ).toMatchObject({ disposition: "steered", tracking: "turn" }); + await expect.poll(() => checkpoints).toContain("running"); + expect((await agent.runs.get("accepted-steer"))?.turnId).toBe( + (await agent.runs.get("initial"))?.turnId, + ); + const command = await agent.send("/side-effect", { messageId: "accepted-command" }); + expect(command).toMatchObject({ disposition: "out_of_band", tracking: "unavailable" }); + expect(await agent.runs.get("accepted-command")).toBeNull(); + expect(await agent.send("/side-effect", { messageId: "accepted-command" })).toEqual(command); + } + expect(starts).toBe(2); + expect(commands).toBe(2); + release(); + for (const agent of agents) { + await expect + .poll(async () => (await agent.runs.get("accepted-steer"))?.status) + .toBe("completed"); + expect((await agent.runs.get("initial"))?.status).toBe("completed"); + const completed = agent.runs.observe("accepted-steer"); + expect((await completed.ready).run?.status).toBe("completed"); + await completed.release(); + } + } finally { + release(); + } +}); + +test("pending message receipt across restart exposes unknown delivery and conflict without resending", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-message-receipt-restart-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + let directory = ""; + let starts = 0; + let injectFailure = true; + const clients = () => + createTestAgentClients({ + onStartTurn: () => { + starts++; + if (!injectFailure) return; + injectFailure = false; + renameSync(directory, `${directory}.saved`); + writeFileSync(directory, "Receipt storage temporarily unavailable"); + }, + }); + const first = await createTestPaseoDaemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + }); + cleanup.push(() => first.close()); + directory = join(first.paseoHome, "agent-requests"); + const origin = await api(first); + const agent = await origin.paseo.agents.create({ + cwd: first.staticDir, + config: { provider: "codex/test" }, + }); + try { + await expect(agent.send("Reply once", { messageId: "uncertain" })).rejects.toMatchObject({ + code: "agent_request_outcome_unknown", + outcome: "unknown", + }); + } finally { + rmSync(directory, { force: true }); + renameSync(`${directory}.saved`, directory); + } + await first.close(); + const restarted = await createTestPaseoDaemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + const restored = next.paseo.agents.ref(agent.id); + await expect(restored.send("Reply once", { messageId: "uncertain" })).rejects.toMatchObject({ + code: "agent_request_outcome_unknown", + outcome: "unknown", + }); + const conflict = await restored + .send("Different request", { messageId: "uncertain" }) + .catch((error: unknown) => error); + expect(conflict).toMatchObject({ code: "agent_request_key_conflict" }); + expect(conflict).not.toHaveProperty("outcome", "unknown"); + expect(starts).toBe(1); +}); + +test.each([false, true])( + "archiving a parent completes its schedule and children despite failing observer: %s", + async (failing) => { + const instance = await daemon(); + const { paseo } = await api(instance); + const parent = await paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + const child = await parent.subagents.create({ config: { provider: "codex/test" } }); + const schedule = await paseo.schedules.create({ + prompt: "Scheduled parent work", + cadence: { type: "cron", expression: "0 0 * * *" }, + target: { type: "agent", agentId: parent.id }, + runOnCreate: false, + }); + expect(schedule.error).toBeNull(); + const stop = instance.daemon.agentManager.onAgentArchived(() => { + if (failing) throw new Error("Independent listener failed"); + }); + cleanup.push(async () => stop()); + await parent.archive(); + expect((await paseo.schedules.inspect({ id: schedule.schedule!.id })).schedule?.status).toBe( + "completed", + ); + expect((await parent.subagents.list()).rows[0]).toMatchObject({ + child: child.address, + phase: "archived", + }); + expect((await child.refresh())?.agent.archivedAt).toBeTruthy(); + }, +); + +test("parented modern creation retains one reserved identity and exact child directory across retries", async () => { + let prompts = 0; + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn: () => { + prompts++; + }, + }), + }); + cleanup.push(() => instance.close()); + const origin = await api(instance); + const parent = await origin.paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + const directory = join(instance.staticDir, "nested-child"); + await mkdir(directory); + const request = { + agentId: crypto.randomUUID(), + idempotencyKey: "parented-original-key", + callerAgentId: parent.id, + config: { provider: "codex", model: "test", cwd: directory }, + initialPrompt: "Reply once for this child", + clientMessageId: "original-initial-prompt", + }; + const child = await origin.client.createAgent(request); + expect(child).toMatchObject({ id: request.agentId, cwd: directory, parent: parent.address }); + expect((await parent.subagents.list()).rows).toHaveLength(1); + expect((await origin.client.createAgent(request)).id).toBe(child.id); + expect(prompts).toBe(1); +}); + +test("parented workspace creation retains its original receipt and both resources across retry", async () => { + let prompts = 0; + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn: () => { + prompts++; + }, + }), + }); + cleanup.push(() => instance.close()); + const origin = await api(instance); + const parent = await origin.paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + const directory = join(instance.staticDir, "workspace-child"); + await mkdir(directory); + const workspaceId = "wks_0123456789abcdef"; + const agentId = crypto.randomUUID(); + const request = { + idempotencyKey: "original-workspace-key", + workspaceId, + source: { kind: "directory" as const, path: instance.staticDir }, + agent: { + agentId, + callerAgentId: parent.id, + config: { provider: "codex", model: "test", cwd: directory }, + initialPrompt: "Reply once from workspace child", + clientMessageId: "workspace-initial", + }, + }; + const created = await origin.client.createWorkspace(request); + expect(created.error).toBeNull(); + expect(created.workspace?.id).toBe(workspaceId); + const child = origin.paseo.agents.ref(agentId); + expect((await child.refresh())?.agent).toMatchObject({ + workspaceId, + cwd: directory, + parent: parent.address, + }); + expect((await parent.subagents.list()).rows).toHaveLength(1); + const repeated = await origin.client.createWorkspace(request); + expect(repeated.workspace?.id).toBe(workspaceId); + expect(prompts).toBe(1); + const receipt = origin.paseo.creations.observe({ + kind: "workspace", + idempotencyKey: request.idempotencyKey, + }); + expect((await receipt.ready).snapshot).toMatchObject({ + kind: "workspace", + agentId, + workspaceId, + phase: "completed", + }); + await receipt.release(); +}); + +test("workspace creation cancellation fences both resources across restart without crossing key namespaces", async () => { + const root = await mkdtemp(join(tmpdir(), "workspace-creation-cancel-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + let starts = 0; + const clients = () => + createTestAgentClients({ + onStartTurn: () => { + starts++; + }, + }); + const first = await createTestPaseoDaemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + }); + cleanup.push(() => first.close()); + const origin = await api(first); + const idempotencyKey = "same-textual-key"; + const workspaceId = "wks_1234567890abcdef"; + const agentId = crypto.randomUUID(); + const reservation = { kind: "workspace" as const, workspaceId, agentId, idempotencyKey }; + expect(await origin.paseo.creations.cancel(reservation)).toMatchObject({ + status: "canceled", + reservation: { + kind: "workspace", + reservedAgent: { serverId: first.daemon.getServerId(), agentId }, + reservedWorkspace: { serverId: first.daemon.getServerId(), workspaceId }, + }, + }); + for (const mismatch of [ + { ...reservation, agentId: crypto.randomUUID() }, + { ...reservation, workspaceId: "wks_1234567890abcdee" }, + ]) + await expect(origin.paseo.creations.cancel(mismatch)).rejects.toMatchObject({ + code: "agent_request_key_conflict", + }); + const independent = await origin.paseo.agents.create({ + idempotencyKey, + cwd: first.staticDir, + config: { provider: "codex/test" }, + }); + expect(independent.id).not.toBe(agentId); + const request = { + idempotencyKey, + workspaceId, + source: { kind: "directory" as const, path: first.staticDir }, + agent: { + agentId, + config: { provider: "codex", model: "test", cwd: first.staticDir }, + initialPrompt: "Must never run", + }, + }; + expect(await origin.client.createWorkspace(request)).toMatchObject({ + errorCode: "creation_canceled", + workspace: null, + }); + await first.close(); + const restarted = await createTestPaseoDaemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + expect(await next.client.createWorkspace(request)).toMatchObject({ + errorCode: "creation_canceled", + workspace: null, + }); + expect( + (await next.paseo.workspaces.list()).entries.some((workspace) => workspace.id === workspaceId), + ).toBe(false); + expect(await next.paseo.agents.ref(agentId).refresh()).toBeNull(); + expect(starts).toBe(0); +}); + +test("admitted workspace cancellation returns its existing receipt and leaves its runner intact", 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 origin = await api(instance); + const agentId = crypto.randomUUID(); + const workspaceId = "wks_abcdef0123456789"; + const idempotencyKey = "workspace-won-admission"; + const request = { + idempotencyKey, + workspaceId, + source: { kind: "directory" as const, path: instance.staticDir }, + agent: { + agentId, + config: { provider: "codex", model: "test", cwd: instance.staticDir }, + initialPrompt: "Run once after admission", + }, + }; + const creating = origin.client.createWorkspace(request); + void creating.catch(() => {}); + try { + await ready; + expect( + await origin.paseo.creations.cancel({ + kind: "workspace", + idempotencyKey, + workspaceId, + agentId, + }), + ).toMatchObject({ + status: "admitted", + creation: { kind: "workspace", workspaceId, agentId, phase: "workspace_ready" }, + }); + await expect( + origin.paseo.creations.cancel({ + kind: "workspace", + idempotencyKey, + workspaceId: "wks_abcdef0123456788", + agentId, + }), + ).rejects.toMatchObject({ code: "agent_request_key_conflict" }); + expect( + await origin.paseo.creations.cancel({ kind: "agent", idempotencyKey, agentId }), + ).toMatchObject({ status: "canceled", reservation: { kind: "agent" } }); + release(); + expect((await creating).workspace?.id).toBe(workspaceId); + expect(prompts).toBe(1); + expect( + await origin.paseo.creations.cancel({ + kind: "workspace", + idempotencyKey, + workspaceId, + agentId, + }), + ).toMatchObject({ status: "admitted", creation: { phase: "completed" } }); + } finally { + release(); + } +}); + +test.each([false, true])( + "legacy parented creation preserves exact directory and prompt with worktree: %s", + async (worktree) => { + let prompts = 0; + const instance = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn: () => { + prompts++; + }, + }), + }); + cleanup.push(() => instance.close()); + const origin = await api(instance); + const directory = join(instance.staticDir, "legacy-nested"); + await mkdir(directory); + if (worktree) { + const git = (args: string[]) => promisify(execFile)("git", args, { cwd: instance.staticDir }); + await git(["init", "-b", "main"]); + await git(["config", "user.name", "Paseo Test"]); + await git(["config", "user.email", "test@example.com"]); + await writeFile(join(directory, "README.md"), "Track exact launch directory"); + await git(["add", "."]); + await git(["commit", "-m", "Initial"]); + } + const parent = await origin.paseo.agents.create({ + cwd: instance.staticDir, + config: { provider: "codex/test" }, + }); + const socket = new WebSocket(`ws://127.0.0.1:${instance.port}/ws`); + cleanup.push(async () => socket.terminate()); + const messages: SessionOutboundMessage[] = []; + socket.on("message", (data) => { + const parsed = WSOutboundMessageSchema.parse(JSON.parse(String(data))); + if (parsed.type === "session") messages.push(parsed.message); + }); + await once(socket, "open"); + const hello = once(socket, "message"); + socket.send( + JSON.stringify({ + type: "hello", + clientId: "legacy-parented", + clientType: "cli", + protocolVersion: 1, + }), + ); + await hello; + const request = { + type: "create_agent_request", + ...(worktree ? { worktree: { mode: "branch-off", newBranch: "legacy-child" } } : {}), + callerAgentId: parent.id, + config: { provider: "codex", model: "test", cwd: directory }, + labels: {}, + attachments: [], + initialPrompt: "Run legacy child once", + clientMessageId: "legacy-initial", + }; + for (const attempt of [1, 2]) { + const requestId = "legacy-original"; + socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } })); + await expect + .poll( + () => + messages.filter( + (message) => message.type === "status" && message.payload.requestId === requestId, + ).length, + ) + .toBe(attempt); + const response = messages.find( + (message) => message.type === "status" && message.payload.requestId === requestId, + ); + expect( + response?.type === "status" && "error" in response.payload + ? response.payload.error + : undefined, + ).toBeUndefined(); + expect(response).toMatchObject({ + type: "status", + payload: { status: "agent_created", agent: { parent: parent.address } }, + }); + } + const rows = (await parent.subagents.list()).rows; + expect(rows).toHaveLength(1); + const child = origin.paseo.agents.ref(rows[0]!.child.agentId); + const snapshot = (await child.refresh())!.agent; + if (worktree) { + expect(snapshot.cwd).not.toBe(directory); + const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], { + cwd: snapshot.cwd, + }); + expect(snapshot.cwd).toBe(join(stdout.trim(), "legacy-nested")); + expect(snapshot.workspaceId).not.toBe((await parent.refresh())!.agent.workspaceId); + } else expect(snapshot.cwd).toBe(directory); + expect(prompts).toBe(1); + }, +); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 49ce95d293..73f5e00911 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -343,7 +343,9 @@ test("cancellation preserves an admitted outcome-unknown receipt after owner res void f.service.create(input); await entered.promise; const restarted = new CreationService(f.directory, silentLogger); - expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({ + expect( + await restarted.cancel({ kind: "agent", idempotencyKey: input.key, agentId: agent.id }), + ).toMatchObject({ status: "admitted", creation: { phase: "accepted", agentId: agent.id }, }); @@ -353,7 +355,9 @@ test("cancellation preserves an admitted outcome-unknown receipt after owner res failedStage: "agent", outcomeUnknown: true, }); - expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({ + expect( + await restarted.cancel({ kind: "agent", idempotencyKey: input.key, agentId: agent.id }), + ).toMatchObject({ status: "admitted", creation: { phase: "failed", outcomeUnknown: true }, }); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index 9f3974b82b..62f954554a 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import { z } from "zod"; import { CreationSnapshotSchema, + CreationCancelInputSchema, + type CreationCancelInput, type AgentSnapshotPayload, type CreationSnapshot, type WorkspaceDescriptorPayload, @@ -128,42 +130,59 @@ export class CreationService { 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); + const canceled = await this.readCancellation(identity); + if (!canceled) return null; + if ( + (input.agentId && input.agentId !== canceled.agentId) || + (canceled.kind === "workspace" && + input.workspaceId && + input.workspaceId !== canceled.workspaceId) + ) + throw conflict(); + return cancellationSnapshot(canceled); } /** Fences a creation identity only before admission. Running receipts remain owned by their runner. */ - cancelAgent( - key: string, - agentId: string, + cancel( + input: CreationCancelInput, ): Promise<{ status: "canceled" } | { status: "admitted"; creation: CreationSnapshot }> { - const identity = identityFor("agent", key); + const { idempotencyKey: key, agentId } = input; + const identity = identityFor(input.kind, 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(); + if ( + record.snapshot.agentId !== agentId || + (input.kind === "workspace" && record.snapshot.workspaceId !== input.workspaceId) + ) + 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 ( + input.kind === "agent" && 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(); + const canceled = await this.readCancellation(identity); + if ( + canceled && + (canceled.agentId !== agentId || + (input.kind === "workspace" && + canceled.kind === "workspace" && + canceled.workspaceId !== input.workspaceId)) + ) + throw conflict(); await writeFileAtomic( join(this.directory, `${identity}.canceled.json`), - JSON.stringify({ agentId }), + JSON.stringify(input), ); - const snapshot = cancellationSnapshot(key, agentId); + const snapshot = cancellationSnapshot(input); for (const observer of this.observers.get(identity) ?? []) this.notify(observer, snapshot); return { status: "canceled" as const }; }); @@ -173,11 +192,9 @@ export class CreationService { ); return operation; } - private async readCancellation(identity: string): Promise { + 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; + return text === null ? null : CreationCancelInputSchema.parse(JSON.parse(text)); } async subscribe( @@ -199,10 +216,9 @@ export class CreationService { }); } if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); - const canceledAgentId = kind === "agent" ? await this.readCancellation(identity) : null; + const canceled = await this.readCancellation(identity); return { - snapshot: - record?.snapshot ?? (canceledAgentId ? cancellationSnapshot(key, canceledAgentId) : null), + snapshot: record?.snapshot ?? (canceled ? cancellationSnapshot(canceled) : null), unsubscribe, }; } catch (error) { @@ -444,16 +460,16 @@ function conflict(): Error { code: "agent_request_key_conflict", }); } -function cancellationSnapshot(key: string, agentId: string): CreationSnapshot { +function cancellationSnapshot(input: CreationCancelInput): CreationSnapshot { return { - kind: "agent", - idempotencyKey: key, + kind: input.kind, + idempotencyKey: input.idempotencyKey, revision: 0, phase: "failed", - workspaceId: null, - agentId, + workspaceId: input.kind === "workspace" ? input.workspaceId : null, + agentId: input.agentId, error: "Creation was canceled before admission", errorCode: "creation_canceled", - failedStage: "agent", + failedStage: input.kind, }; } diff --git a/packages/server/src/server/message-receipts/index.test.ts b/packages/server/src/server/message-receipts/index.test.ts index 10c1615a6a..c952e97427 100644 --- a/packages/server/src/server/message-receipts/index.test.ts +++ b/packages/server/src/server/message-receipts/index.test.ts @@ -75,3 +75,23 @@ test("failed local message preparation does not leave an ambiguous receipt", asy await requests.send(input); expect(sends).toBe(1); }); + +test("accepted untracked and suppressed dispatch evidence survives receipt reconstruction", async () => { + const { requests, directory } = await fixture(); + for (const disposition of ["out_of_band", "suppressed"] as const) { + let sends = 0; + const dispatch = { disposition, tracking: "unavailable" as const }; + const input = { + agentId: "agent", + messageId: disposition, + request: { disposition }, + send: async () => { + sends++; + return dispatch; + }, + }; + expect(await requests.send(input)).toEqual(dispatch); + expect(await new MessageReceipts(directory).send(input)).toEqual(dispatch); + expect(sends).toBe(1); + } +}); diff --git a/packages/server/src/server/message-receipts/index.ts b/packages/server/src/server/message-receipts/index.ts index 6103160b12..8d87e312d1 100644 --- a/packages/server/src/server/message-receipts/index.ts +++ b/packages/server/src/server/message-receipts/index.ts @@ -1,3 +1,4 @@ +import { AgentDispatchSchema, type AgentDispatch } from "@getpaseo/protocol/agent-dispatch"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -8,21 +9,22 @@ const ReceiptSchema = z.object({ fingerprint: z.string(), state: z.enum(["pending", "completed"]), agentId: z.string(), + dispatch: AgentDispatchSchema.optional(), }); interface SendMessageInput { agentId: string; messageId: string; request: unknown; - send: () => Promise; + send: () => Promise; prepare?: () => Promise; } /** Owns message delivery receipts; creation is owned by CreationService. */ export class MessageReceipts { - private readonly pending = new Map>(); + private readonly pending = new Map>(); constructor(private readonly directory: string) {} - send(input: SendMessageInput): Promise { + send(input: SendMessageInput): Promise { // Preserve the existing on-disk identity and shape across daemon upgrades. const key = digest(["send", input.agentId, input.messageId]); const previous = this.pending.get(key); @@ -38,21 +40,42 @@ export class MessageReceipts { return result; } - private async sendOnce(key: string, input: SendMessageInput): Promise { + private async sendOnce(key: string, input: SendMessageInput): Promise { const file = path.join(this.directory, `${key}.json`); const fingerprint = digest(input.request); const existing = await readReceipt(file); if (existing) { - if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); - if (existing.state === "completed") return; + if (existing.fingerprint !== fingerprint) + throw Object.assign(new Error("agent_request_key_conflict"), { + code: "agent_request_key_conflict", + }); + if (existing.state === "completed") return existing.dispatch; // A provider may have accepted the message before its receipt was committed. - throw new Error("agent_request_outcome_unknown"); + throw Object.assign(new Error("agent_request_outcome_unknown"), { + code: "agent_request_outcome_unknown", + outcome: "unknown", + }); } await input.prepare?.(); const receipt = { fingerprint, agentId: input.agentId }; await writeJsonFileAtomic(file, { ...receipt, state: "pending" }); - await input.send(); - await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); + const dispatch = await input.send(); + try { + await writeJsonFileAtomic(file, { + ...receipt, + state: "completed", + ...(dispatch ? { dispatch } : {}), + }); + } catch (cause) { + throw Object.assign( + new Error("Message was dispatched but its receipt could not be committed", { cause }), + { + code: "agent_request_outcome_unknown", + outcome: "unknown", + }, + ); + } + return dispatch; } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c4c61bbfd1..e5d9e566a3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,5 @@ +import { fromMessageSource } from "./agent/agent-messages/index.js"; +import type { AgentDispatch } from "@getpaseo/protocol/agent-dispatch"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { Subagents, SubagentAuthority } from "./subagents/index.js"; import { AgentDirectory } from "./agent/directory.js"; @@ -473,7 +475,7 @@ export interface SessionOptions { agentManager: AgentManager; agentStorage: AgentStorage; messageReceipts: Pick; - creationService: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -831,7 +833,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 { @@ -2949,7 +2951,7 @@ export class Session { source?: object, ): Promise | undefined { switch (msg.type) { - case "agent.create.cancel.request": + case "creation.cancel.request": return this.handleAgentCreationCancel(msg); case "agent.create.request": return this.handleAgentCreation(msg, source); @@ -2963,22 +2965,29 @@ export class Session { } private async handleAgentCreationCancel( - message: Extract, + 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 result = await this.creationService.cancel(message.creation); const serverId = this.subagentOptions.serverId; if (!serverId) throw new Error("Daemon identity is unavailable"); this.emit({ - type: "agent.create.cancel.response", + type: "creation.cancel.response", payload: { requestId: message.requestId, - cancellation: { ...result, reservedAgent: { serverId, agentId: message.agentId } }, + cancellation: { + ...result, + reservation: + message.creation.kind === "workspace" + ? { + kind: "workspace", + reservedAgent: { serverId, agentId: message.creation.agentId }, + reservedWorkspace: { serverId, workspaceId: message.creation.workspaceId }, + } + : { kind: "agent", reservedAgent: { serverId, agentId: message.creation.agentId } }, + }, }, }); } catch (error) { @@ -2997,14 +3006,84 @@ export class Session { } } + private async handleAgentClose( + message: Extract, + ): Promise { + try { + this.subagentOptions.subagentAuthority?.(); + await closeAgentCommand({ agentManager: this.agentManager }, message.agentId); + this.emit({ type: "agent.close.response", payload: { requestId: message.requestId } }); + } catch (error) { + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: + error instanceof Error && "code" in error ? String(error.code) : "agent_close_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 } }); + try { + this.subagentOptions.subagentAuthority?.(); + if (message.observe) { + let stop = () => {}; + const owner = this.delivery.begin("agent-runs", undefined, () => stop(), message.agentId); + try { + stop = await this.agentManager.observeAgentRun( + message.agentId, + message.submissionId, + (run) => + owner.emit({ + type: "agent.runs.get.response", + payload: { + requestId: message.requestId, + subscriptionId: owner.id, + run, + }, + }), + (run) => + owner.emit({ + type: "agent.runs.changed", + payload: { subscriptionId: owner.id, run }, + }), + ); + if (owner.signal.aborted) stop(); + } catch (error) { + await owner.release(); + throw error; + } + return; + } + const run = await this.agentManager.getAgentRun(message.agentId, message.submissionId); + this.emit({ + type: "agent.runs.get.response", + payload: { requestId: message.requestId, run }, + }); + } catch (error) { + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: + error instanceof Error && "code" in error + ? String(error.code) + : "agent_run_read_failed", + error: error instanceof Error ? error.message : String(error), + }, + }); + } } private dispatchSubagentMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "agent.close.request") return this.handleAgentClose(msg); if (msg.type === "agent.runs.get.request") return this.handleAgentRunGet(msg); if ( msg.type === "agent.subagents.create.request" || @@ -4271,10 +4350,29 @@ export class Session { ? (await this.workspaceRegistry.get(id)) !== null : this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null; - private createRequestedAgent( + private async createRequestedAgent( request: AgentCreateRequest, observer?: (snapshot: CreationSnapshot) => void, ): Promise { + // COMPAT(qualifiedSubagents): added in v0.12, remove after 2027-04-20. + // Normalize legacy parent placement before the creation owner reserves its resource. + const legacyParentId = request.callerAgentId ?? request.labels[PARENT_AGENT_ID_LABEL]; + if (legacyParentId && this.subagentOptions.subagents) { + const key = request.idempotencyKey ?? request.requestId; + const observing = await this.creationService.subscribe("agent", key, observer ?? (() => {})); + try { + await this.createLegacySubagent( + { ...request, type: "create_agent_request", idempotencyKey: key }, + request.agentId, + ); + const receipt = await this.creationService.subscribe("agent", key, () => {}); + receipt.unsubscribe(); + if (!receipt.snapshot) throw new Error("Subagent creation owner omitted its receipt"); + return receipt.snapshot; + } finally { + observing.unsubscribe(); + } + } const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; return this.creationService.create( { @@ -4349,7 +4447,7 @@ export class Session { request: Extract, source?: object, ): Promise { - const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request; + const { requestId, subscribe } = request; const progress = subscribe ? this.delivery.operation( (message) => @@ -4365,67 +4463,8 @@ export class Session { ) { throw new Error("Session is not authorized to create an agent"); } - const agentInput = request.agent; - const creation = await this.creationService.create( - { - kind: "workspace", - key: idempotencyKey ?? requestId, - request: intent, - workspaceId: request.workspaceId, - agentId: agentInput?.agentId, - hasAgent: Boolean(agentInput), - hasPrompt: hasInitialCreationPrompt(agentInput), - exists: this.creationResourceExists, - provision: async (id) => { - const descriptor = await this.createRequestedWorkspace( - { - ...request, - firstAgentContext: agentInput - ? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments } - : request.firstAgentContext, - }, - id, - ); - const workspace = await this.workspaceRegistry.get(descriptor.id); - return { - workspace: descriptor, - setupSkippedReason: workspace?.untrustedSource - ? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource) - : undefined, - }; - }, - createAgent: agentInput - ? async (id, workspace, onReady) => { - if (!workspace?.workspaceDirectory) - throw new Error("Created workspace has no directory"); - const sourceCwd = - request.source.kind === "directory" - ? request.source.path - : await resolveWorktreeSourceCwd(request.source, this.projectRegistry); - const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd)); - if ( - relativeCwd === ".." || - relativeCwd.startsWith(`..${sep}`) || - isAbsolute(relativeCwd) - ) - throw new Error("Agent directory must be inside the workspace source"); - return this.createSessionAgent( - { - ...agentInput, - type: "create_agent_request", - requestId, - config: { - ...agentInput.config, - cwd: resolve(workspace.workspaceDirectory, relativeCwd), - }, - workspaceId: workspace.id, - }, - id, - onReady, - ); - } - : undefined, - }, + const creation = await this.createRequestedWorkspaceWithAgent( + request, progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, ); this.emitForSource( @@ -4466,6 +4505,114 @@ export class Session { } } + private async createRequestedWorkspaceWithAgent( + request: Extract, + observer?: (snapshot: CreationSnapshot) => void, + ): Promise { + const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; + const agentInput = request.agent; + const parentId = agentInput?.callerAgentId ?? agentInput?.labels[PARENT_AGENT_ID_LABEL]; + const { subagents, subagentAuthority, serverId } = this.subagentOptions; + if (agentInput && parentId && subagents && subagentAuthority && serverId) { + const key = idempotencyKey ?? requestId; + const observing = await this.creationService.subscribe( + "workspace", + key, + observer ?? (() => {}), + ); + try { + const { callerAgentId: _caller, parent: _parent, ...input } = agentInput; + const { + type: _workspaceType, + requestId: _workspaceRequestId, + subscribe: _subscribeWorkspace, + agent: _agent, + ...workspace + } = request; + const labels = { ...input.labels }; + delete labels[PARENT_AGENT_ID_LABEL]; + await subagents.create( + { serverId, agentId: parentId }, + { + serverId, + idempotencyKey: key, + workspace, + input: { ...input, labels, idempotencyKey: key }, + }, + subagentAuthority(), + ); + const receipt = await this.creationService.subscribe("workspace", key, () => {}); + receipt.unsubscribe(); + if (!receipt.snapshot) throw new Error("Workspace creation owner omitted its receipt"); + return receipt.snapshot; + } finally { + observing.unsubscribe(); + } + } + return this.creationService.create( + { + kind: "workspace", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: agentInput?.agentId, + hasAgent: Boolean(agentInput), + hasPrompt: hasInitialCreationPrompt(agentInput), + exists: this.creationResourceExists, + provision: async (id) => { + const descriptor = await this.createRequestedWorkspace( + { + ...request, + firstAgentContext: agentInput + ? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments } + : request.firstAgentContext, + }, + id, + ); + const workspace = await this.workspaceRegistry.get(descriptor.id); + return { + workspace: descriptor, + setupSkippedReason: workspace?.untrustedSource + ? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource) + : undefined, + }; + }, + createAgent: agentInput + ? async (id, workspace, onReady) => { + if (!workspace?.workspaceDirectory) + throw new Error("Created workspace has no directory"); + const sourceCwd = + request.source.kind === "directory" + ? request.source.path + : await resolveWorktreeSourceCwd(request.source, this.projectRegistry); + const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd)); + if ( + relativeCwd === ".." || + relativeCwd.startsWith(`..${sep}`) || + isAbsolute(relativeCwd) + ) + throw new Error("Agent directory must be inside the workspace source"); + return this.createSessionAgent( + { + ...agentInput, + type: "create_agent_request", + requestId, + config: { + ...agentInput.config, + cwd: resolve(workspace.workspaceDirectory, relativeCwd), + }, + workspaceId: workspace.id, + }, + id, + onReady, + ); + } + : undefined, + }, + observer, + ); + } + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { try { let agent: AgentSnapshotPayload; @@ -4560,7 +4707,7 @@ export class Session { { serverId, idempotencyKey: msg.idempotencyKey ?? msg.requestId, - input: { ...input, labels, agentId }, + input: { ...input, labels, agentId, idempotencyKey: msg.idempotencyKey ?? msg.requestId }, }, subagentAuthority(), ); @@ -4730,6 +4877,9 @@ export class Session { } let config = request.config; + // The worktree workflow already maps the requested subdirectory into its checkout. + // Workspace identity remains separate from an existing agent's launch directory. + const launchCwd = createdWorktree?.workspace.cwd ?? config.cwd; const intent = await resolveCreateAgentIntent({ explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId, @@ -4739,13 +4889,13 @@ export class Session { labels: request.labels, resolveWorkspace: async (workspaceId) => { if (createdWorktree?.workspace.workspaceId === workspaceId) { - return { workspaceId, cwd: createdWorktree.workspace.cwd }; + return { workspaceId, cwd: launchCwd }; } const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace || workspace.archivedAt) { throw new Error(`Workspace ${workspaceId} not found`); } - return { workspaceId, cwd: workspace.cwd }; + return { workspaceId, cwd: launchCwd }; }, createWorkspace: async () => ({ workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({ @@ -5007,7 +5157,7 @@ export class Session { this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`); try { - await cancelAgentRunCommand( + const { cancelled } = await cancelAgentRunCommand( { agentManager: this.agentManager, logger: this.sessionLogger }, agentId, ); @@ -5020,6 +5170,7 @@ export class Session { requestId, agentId, agent: payload, + cancelled, error: null, }, }); @@ -8488,6 +8639,13 @@ export class Session { } } + private legacyMessageSource( + agentId: string | undefined, + ): import("./agent/agent-messages/index.js").AgentPromptSource | undefined { + // COMPAT(local-message-source): added in v0.12, remove after 2027-04-20. + return agentId ? { kind: "agent-message", agentId } : undefined; + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -8513,21 +8671,23 @@ export class Session { { agentId, messageId: msg.messageId, + unarchive: msg.unarchive, activeTurnBehavior: msg.activeTurnBehavior, textPrefix: msg.text.slice(0, 80), }, "agent.session.send_agent_message", ); - const send = async () => { + const send = async (): Promise => { const result = await sendPromptToAgent({ agentManager: this.agentManager, agentStorage: this.agentStorage, agentId, prompt, - source: msg.sourceAgentId - ? { kind: "agent-message", agentId: msg.sourceAgentId } - : undefined, + source: msg.source + ? fromMessageSource(msg.source) + : this.legacyMessageSource(msg.sourceAgentId), messageId: msg.messageId, + unarchive: msg.unarchive, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", clearPendingPermissions: true, logger: this.sessionLogger, @@ -8539,23 +8699,37 @@ export class Session { this.delivery.requestSignal, ); } + return { + disposition: result.disposition, + tracking: + result.disposition === "turn_started" || result.disposition === "steered" + ? "turn" + : "unavailable", + }; }; + let dispatch: AgentDispatch | void; if (msg.messageId) { - await this.messageReceipts.send({ + dispatch = await this.messageReceipts.send({ agentId, messageId: msg.messageId, request: { prompt, sourceAgentId: msg.sourceAgentId, + ...(msg.source ? { source: msg.source } : {}), activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", + ...(msg.unarchive !== undefined ? { unarchive: msg.unarchive } : {}), }, prepare: async () => { - if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text); + if (!msg.sourceAgentId && !msg.source) { + const recipient = await this.agentStorage.get(agentId); + if (msg.unarchive !== false || !recipient?.archivedAt) + await this.prepareAgentMessage(agentId, msg.text); + } }, send, }); } else { - await send(); + dispatch = await send(); } this.emit({ @@ -8564,6 +8738,7 @@ export class Session { requestId: msg.requestId, agentId, accepted: true, + ...(dispatch ? { dispatch } : {}), error: null, }, }); @@ -8577,6 +8752,12 @@ export class Session { agentId: resolved.agentId, accepted: false, error: errorToFriendlyMessage(error), + ...(error instanceof Error && "code" in error && typeof error.code === "string" + ? { code: error.code } + : {}), + ...(error instanceof Error && "outcome" in error && error.outcome === "unknown" + ? { outcome: "unknown" as const } + : {}), }, }); } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index bd6c58fd6e..c352c53500 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,4 @@ +import { SessionInboundMessageSchema } from "@getpaseo/protocol/messages"; import { createMessageReceiptsStub, createTestCreationService, @@ -1053,13 +1054,20 @@ test("create_agent_request keeps requested child cwd when grouped under an exist terminalManager: null, }), ); - await session.handleMessage({ - type: "create_agent_request", - requestId: "req-create-child", - config: { provider: "codex", cwd: child }, - attachments: [], - }); + await session.handleMessage( + SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "req-create-child", + config: { provider: "codex", cwd: child }, + attachments: [], + }), + ); + expect( + emitted.find( + (message) => message.type === "status" && message.payload.status === "agent_create_failed", + ), + ).toBeUndefined(); const [createdAgent] = agentManager.listAgents(); expect(createdAgent?.cwd).toBe(child); const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!); @@ -1218,14 +1226,21 @@ test("create_agent_request launches from an exact subdirectory in a created work terminalManager: null, }); - await session.handleMessage({ - type: "create_agent_request", - requestId: "req-create-worktree-child", - config: { provider: "codex", cwd: child }, - attachments: [], - worktree: { mode: "branch-off", newBranch: "feature/created-worktree" }, - }); + await session.handleMessage( + SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "req-create-worktree-child", + config: { provider: "codex", cwd: child }, + attachments: [], + worktree: { mode: "branch-off", newBranch: "feature/created-worktree" }, + }), + ); + expect( + emitted.find( + (message) => message.type === "status" && message.payload.status === "agent_create_failed", + ), + ).toBeUndefined(); const [createdAgent] = agentManager.listAgents(); const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: createdAgent!.cwd, @@ -1353,14 +1368,16 @@ test("create_agent_request does not title an existing workspace from the agent p }), ); - await session.handleMessage({ - type: "create_agent_request", - requestId: "req-create-existing-title", - workspaceId: "ws-existing", - config: { provider: "codex", cwd }, - initialPrompt: "Fix login bug\nwith better validation", - attachments: [], - }); + await session.handleMessage( + SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "req-create-existing-title", + workspaceId: "ws-existing", + config: { provider: "codex", cwd }, + initialPrompt: "Fix login bug\nwith better validation", + attachments: [], + }), + ); await vi.runAllTimersAsync(); const [createdAgent] = agentManager.listAgents(); diff --git a/packages/server/src/server/subagents/index.ts b/packages/server/src/server/subagents/index.ts index e7edf3bf3d..fb608aa699 100644 --- a/packages/server/src/server/subagents/index.ts +++ b/packages/server/src/server/subagents/index.ts @@ -237,8 +237,16 @@ export class Subagents { if (snapshot.archivedAt) throw failure("invalid_parent", "Cannot attach a child to an archived parent"); creation = SubagentCreateInputSchema.parse(creation); + const id = createHash("sha256") + .update(JSON.stringify([authority.sourceId, parent, creation.idempotencyKey])) + .digest("hex"); let workspaceId = creation.input.workspaceId; - if (!workspaceId) { + if (creation.workspace) { + if (workspaceId) + throw failure("invalid_placement", "Choose a new workspace or an existing workspace"); + workspaceId = creation.workspace.workspaceId ?? `wks_${id.slice(0, 16)}`; + creation.workspace.workspaceId = workspaceId; + } else if (!workspaceId) { if (creation.serverId !== parent.serverId) throw failure("workspace_required", "Choose a workspace on the destination host"); workspaceId = snapshot.workspaceId; @@ -246,9 +254,6 @@ export class Subagents { } 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) { @@ -303,6 +308,9 @@ export class Subagents { } return running; } + private creationKey(record: RelationshipRecord): string { + return record.creation.input.idempotencyKey ?? `subagent:${record.row.id}`; + } private async createChild(record: RelationshipRecord): Promise { const { row, creation } = record; const creationIntent = record.intent; @@ -310,9 +318,8 @@ export class Subagents { 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({ + const agentOptions = { ...input, agentId: row.child.agentId, config: { @@ -321,9 +328,26 @@ export class Subagents { }, parent: row.parent, prompt: initialPrompt, - idempotencyKey: `subagent:${row.id}`, - clientMessageId: `subagent:${row.id}:initial`, - }); + clientMessageId: input.clientMessageId ?? `subagent:${row.id}:initial`, + }; + let agent; + if (creation.workspace) { + await api.workspaces.create({ + ...creation.workspace, + workspaceId: creation.input.workspaceId, + idempotencyKey: this.creationKey(record), + agent: { ...agentOptions, cwd: config.cwd }, + }); + agent = api.agents.ref(row.child.agentId); + await agent.refresh(); + } else { + const workspace = api.workspaces.ref(creation.input.workspaceId!); + agent = await workspace.agents.create({ + ...agentOptions, + ...(config.cwd ? { cwd: config.cwd } : {}), + idempotencyKey: this.creationKey(record), + }); + } await this.transact(async () => { // Creation confirms a resource; it never owns a newer lifecycle decision. if (!terminalConfirmation(record)) record.confirmed = "attached"; @@ -630,7 +654,10 @@ export class Subagents { await api.dispose(); return null; } - const observation = api.creations.observeAgent(`subagent:${record.row.id}`); + const observation = api.creations.observe({ + kind: record.creation.workspace ? "workspace" : "agent", + idempotencyKey: this.creationKey(record), + }); let latest: CreationSnapshot | null = null; const accept = (snapshot: CreationSnapshot | null) => { latest = snapshot; @@ -681,8 +708,11 @@ export class Subagents { 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}`, + const cancellation = await destination.creations.cancel({ + ...(record.creation.workspace + ? { kind: "workspace" as const, workspaceId: record.creation.input.workspaceId! } + : { kind: "agent" as const }), + idempotencyKey: this.creationKey(record), agentId: record.row.child.agentId, }); if (cancellation.status === "canceled") { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 1c7127d144..53e10dc24c 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -58,18 +58,24 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise; + beforeSetFeature?: (featureId: string, value: unknown) => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; historyEvents?: readonly AgentStreamEvent[]; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + tryHandleOutOfBand?: AgentSession["tryHandleOutOfBand"]; } export interface TestAgentClientOptions { beforeCreateSession?: (config: AgentSessionConfig) => Promise; closeSession?: () => Promise; + beforeSetFeature?: (featureId: string, value: unknown) => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; supportsMcpServers?: boolean; historyEvents?: readonly AgentStreamEvent[]; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + tryHandleOutOfBand?: AgentSession["tryHandleOutOfBand"]; } function createDeferred(): Deferred { @@ -327,6 +333,8 @@ function buildLargeTimelineItem(input: { } class FakeAgentSession implements AgentSession { + readonly steerActiveTurn: AgentSession["steerActiveTurn"]; + readonly tryHandleOutOfBand: AgentSession["tryHandleOutOfBand"]; readonly capabilities: AgentCapabilityFlags; readonly id: string; private readonly providerName: string; @@ -341,6 +349,7 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise) | undefined; + private readonly beforeSetFeature: TestAgentClientOptions["beforeSetFeature"]; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; private readonly historyEvents: readonly AgentStreamEvent[] | undefined; @@ -355,9 +364,12 @@ class FakeAgentSession implements AgentSession { this.id = options.sessionId ?? randomUUID(); this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; + this.beforeSetFeature = options.beforeSetFeature; this.onStartTurn = options.onStartTurn; this.beforeTurnComplete = options.beforeTurnComplete; this.historyEvents = options.historyEvents; + this.steerActiveTurn = options.steerActiveTurn; + this.tryHandleOutOfBand = options.tryHandleOutOfBand; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -865,6 +877,7 @@ class FakeAgentSession implements AgentSession { } async setFeature(featureId: string, value: unknown): Promise { + await this.beforeSetFeature?.(featureId, value); this.config.featureValues = { ...this.config.featureValues, [featureId]: value, @@ -1232,9 +1245,12 @@ class FakeAgentClient implements AgentClient { config: { ...config }, supportsMcpServers: this.options.supportsMcpServers, closeSession: this.options.closeSession, + beforeSetFeature: this.options.beforeSetFeature, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, historyEvents: this.options.historyEvents, + steerActiveTurn: this.options.steerActiveTurn, + tryHandleOutOfBand: this.options.tryHandleOutOfBand, }); } @@ -1259,9 +1275,12 @@ class FakeAgentClient implements AgentClient { sessionId: handle.sessionId, memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, + beforeSetFeature: this.options.beforeSetFeature, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, historyEvents: this.options.historyEvents, + steerActiveTurn: this.options.steerActiveTurn, + tryHandleOutOfBand: this.options.tryHandleOutOfBand, }); } diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index df0b02ad43..26620565bb 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -281,6 +281,7 @@ function createVoiceAssistantWebSocketServer(params: { const { httpServer, broker } = params; const agentManager = { setAgentAttentionCallback() {}, + onAgentArchived: () => () => {}, subscribe: () => () => {}, getMetricsSnapshot: () => ({ total: 0, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 8ff692d580..0f49f6acf0 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -87,6 +87,7 @@ function createServer(agentManagerOverrides?: Record) { const agentManager = { subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), + onAgentArchived: () => () => {}, getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })), getLastAssistantMessage: vi.fn(async () => null), getMetricsSnapshot: vi.fn(() => ({ diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index a3c821abd1..ddede8d02b 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -269,6 +269,7 @@ function createServer(options?: { createStub({ subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), + onAgentArchived: () => () => {}, getAgent: vi.fn(() => null), getMetricsSnapshot: vi.fn(() => ({ totalAgents: 0, diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 3f77a0482e..63f6ea0b01 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -118,6 +118,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work const pushNotifications = new RecordingPushNotificationSender(); const agentManager = { setAgentAttentionCallback: vi.fn(), + onAgentArchived: () => () => {}, subscribe: vi.fn(() => () => {}), getAgent: vi.fn(() => null), getLastAssistantMessage: vi.fn(async () => null), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index adefb96594..3b92970d62 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -534,6 +534,7 @@ function requireWebSocketServices(params: { */ export class VoiceAssistantWebSocketServer { private readonly subagents: Subagents; + private readonly stopAgentArchived: () => void; private ownerAdmission: SessionAdmission = OWNER_SESSION_ADMISSION; private readonly hostRuntimes = new Set>>(); private readonly ownedSocketSources = new WeakMap(); @@ -765,7 +766,7 @@ export class VoiceAssistantWebSocketServer { this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"), ); }); - this.agentManager.setAgentArchivedCallback(async (agentId) => { + this.stopAgentArchived = this.agentManager.onAgentArchived(async (agentId) => { const record = await this.agentStorage.get(agentId); await this.subagents.parentArchived( { serverId, agentId }, @@ -1170,6 +1171,7 @@ export class VoiceAssistantWebSocketServer { public async close(): Promise { this.prepareForShutdown(); + this.stopAgentArchived(); await this.subagents.close(); await Promise.allSettled([...this.hostRuntimes].map((runtime) => runtime.dispose())); this.hostRuntimes.clear();