From d63132a37dd49c38af400dbbcd8f8b7ebd519339 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 16:55:42 +0200 Subject: [PATCH] fix(creation): prevent duplicate workspaces and agents on retries (#4442) * fix(creation): prevent duplicate workspaces and agents on retries * test(creation): cover plugin hooks across receipt replays * fix(creation): recover draft retries without accepting partial workspaces * refactor(creation): name receipt inputs and tag compatibility gate * fix(file-observer): track files announced by native change events * fix(creation): keep initial prompts in daemon-owned creation requests * fix(creation): reuse connection-owned progress and recovery * test(creation): adapt handoff gates to combined requests * ci(desktop): avoid xz compression for smoke packages * ci(desktop): pass valid RPM compression through FPM config * refactor(creation): give legacy and modern requests one identity owner * fix(creation): log observer failures without interrupting creation --- .github/workflows/ci.yml | 5 +- docs/architecture.md | 30 ++ docs/hub.md | 4 + ...agent-profile-modeless-preferences.spec.ts | 53 +- .../e2e/browser/creation-idempotency.spec.ts | 130 +++++ ...w-workspace-codex-mode-preferences.spec.ts | 80 +-- packages/app/e2e/support/creation-fixtures.ts | 92 ++++ .../support/helpers/agent-timeline-gate.ts | 27 +- packages/app/e2e/support/helpers/creation.ts | 277 +++++++++++ .../support/helpers/daemon-client-loader.ts | 7 + .../support/helpers/daemon-websocket-gate.ts | 1 + .../helpers/new-workspace-navigation.ts | 9 +- .../app/e2e/support/helpers/new-workspace.ts | 125 ++--- packages/app/e2e/support/helpers/workspace.ts | 7 +- .../src/components/workspace-setup-dialog.tsx | 79 +-- .../app/src/composer/draft/create-flow.ts | 37 +- .../app/src/composer/draft/workspace-tab.tsx | 36 +- packages/app/src/contexts/session-context.tsx | 48 +- .../app/src/screens/new-workspace-screen.tsx | 321 ++++++------ .../app/src/stores/create-flow-store.test.ts | 27 + packages/app/src/stores/create-flow-store.ts | 22 +- .../workspace-draft-submission-store.test.ts | 71 +++ .../workspace-draft-submission-store.ts | 23 +- .../src/stores/workspace-setup-store.test.ts | 2 + .../app/src/stores/workspace-setup-store.ts | 14 +- packages/client/src/creation/index.test.ts | 234 +++++++++ packages/client/src/creation/index.ts | 276 +++++++++++ packages/client/src/daemon-client.test.ts | 166 +++++++ packages/client/src/daemon-client.ts | 121 ++++- packages/client/src/index.ts | 65 ++- packages/protocol/src/messages.ts | 99 +++- .../src/server/agent/create-agent/create.ts | 3 + .../src/server/agent/requests/index.test.ts | 143 ------ .../server/src/server/agent/requests/index.ts | 138 ------ .../authorization/operation-permissions.ts | 6 + .../src/server/creation/creation.e2e.test.ts | 286 +++++++++++ .../server/src/server/creation/index.test.ts | 324 ++++++++++++ packages/server/src/server/creation/index.ts | 351 +++++++++++++ .../internal/native-recursive.test.ts | 127 ++--- .../src/server/message-receipts/index.test.ts | 77 +++ .../src/server/message-receipts/index.ts | 81 +++ .../src/server/paseo-worktree-service.ts | 2 + .../src/server/plugins/lifecycle.e2e.test.ts | 11 +- packages/server/src/server/session.test.ts | 8 +- packages/server/src/server/session.ts | 461 ++++++++++++++---- .../session.workspace-git-watch.test.ts | 8 +- ...on.workspace-resolution-invariants.test.ts | 8 +- .../src/server/session.workspaces.test.ts | 32 +- .../workspace-provisioning-service.ts | 9 +- .../snapshot-mutation-ownership.test.ts | 8 +- .../server/test-utils/fake-agent-client.ts | 2 + .../src/server/test-utils/session-stubs.ts | 28 +- .../server/src/server/websocket-server.ts | 40 +- .../server/src/server/wire-compat.test.ts | 8 +- .../workspace-create-errors.e2e.test.ts | 29 +- 55 files changed, 3702 insertions(+), 976 deletions(-) create mode 100644 packages/app/e2e/browser/creation-idempotency.spec.ts create mode 100644 packages/app/e2e/support/creation-fixtures.ts create mode 100644 packages/app/e2e/support/helpers/creation.ts create mode 100644 packages/app/src/stores/workspace-draft-submission-store.test.ts create mode 100644 packages/client/src/creation/index.test.ts create mode 100644 packages/client/src/creation/index.ts delete mode 100644 packages/server/src/server/agent/requests/index.test.ts delete mode 100644 packages/server/src/server/agent/requests/index.ts create mode 100644 packages/server/src/server/creation/creation.e2e.test.ts create mode 100644 packages/server/src/server/creation/index.test.ts create mode 100644 packages/server/src/server/creation/index.ts create mode 100644 packages/server/src/server/message-receipts/index.test.ts create mode 100644 packages/server/src/server/message-receipts/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b735b2b06c..47ee95f36e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,10 @@ jobs: - name: Build Linux desktop artifacts if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 + # Smoke package contents and installation without spending minutes on xz compression. + run: | + printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json" + npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json" env: EP_GH_IGNORE_TIME: true PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke diff --git a/docs/architecture.md b/docs/architecture.md index 96d8cdcc56..6d21103cb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -319,6 +319,36 @@ Example: adding a new enum value // 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" ``` +### Creation ownership + +Creation is owned by the daemon across socket lifetimes. A workspace request can include +its initial agent and prompt. Workspace readiness is published before provider startup; +the app keeps its existing disk-ready navigation and observes the remaining creation. +Callbacks do not advance the workflow. Resource reservations in acknowledgement are +identities, not ready workspace records. + +The creation journal (`server/creation/`) owns identity and execution for both legacy and +modern creation RPCs. Requesting progress never selects a different journal. Existing +agent receipts are imported at this boundary; message delivery receipts remain separate. +It keeps cumulative milestones and permanent IDs under an operation kind and idempotency +key. Reconnect subscribes to that key; retries +join active work or reuse committed stages. A persisted resource alone cannot prove +that a provider accepted its initial prompt. Interrupted side effects with no conclusive +receipt return an unknown outcome instead of being repeated. + +`packages/client/src/creation/` owns capability selection and legacy orchestration. +Callers always pass the initial prompt to agent creation. On an older host, the client +adapts keyed creation to the legacy create/send sequence; it cannot continue that +sequence after the client process disappears. Explicitly requested IDs or receipts +that an old host cannot honor produce an unsupported error. Keep these adapters inside +the client package, as an exception to the default no-fallback feature policy. + +Creation executes through the existing Session capabilities. Connection-owned delivery +controls observation only: detaching a socket or cleaning up its Session does not cancel +accepted creation. Updates require an explicit subscription and go only to that socket; +reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their +existing response contract. + ## Agent lifecycle The lifecycle states are defined in `shared/agent-lifecycle.ts`: diff --git a/docs/hub.md b/docs/hub.md index bf44067b67..8c6493504a 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -49,6 +49,10 @@ Provider controls remain provider-native; see [providers.md](providers.md). `send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts; creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide; a message ID is scoped to its agent. Reusing either with different arguments is a conflict. +`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and +worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it. +Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys +for the lifetime of a draft and reuses the first message ID after failures. The daemon journals the assigned agent ID before creation. Concurrent retries share one operation, and a retry after a lost acknowledgement or restart returns the durable agent without creating a diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index 2558d3b427..a10afd2cca 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -9,7 +9,7 @@ import { seedModelProvider, } from "../support/helpers/agent-profiles"; import { gotoAppShell } from "../support/helpers/app"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, @@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const MODELESS_PROVIDER = "modeless-profile-e2e"; const MODELESS_MODEL = "pi-profile-model"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - featureValues?: unknown; - }; -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown }; - return envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? (envelope.message as Record) - : null; - } catch { - return null; - } -} - -async function recordAndBlockCreateAgentRequest(page: Page): Promise<{ - waitForRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const request = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - server.onMessage((message) => ws.send(message)); - }); - - return { waitForRequest: () => request }; -} - async function seedPoisonedModelessPreference(page: Page): Promise { await page.addInitScript( ({ preferencesKey, provider, model }) => { @@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { ], }); await seedPoisonedModelessPreference(page); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); @@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { }, ], }); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts new file mode 100644 index 0000000000..39e73dbded --- /dev/null +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -0,0 +1,130 @@ +import type { createCreationScenario } from "../support/helpers/creation"; +import { expect } from "../support/fixtures"; +import { test } from "../support/creation-fixtures"; + +for (const isolation of ["local", "worktree"] as const) { + test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({ + creation, + }) => { + await creation.openWorkspaceForm(isolation); + await creation.submitRepeatedly("Create"); + await creation.expectOneCreatedWorkspace(); + }); +} + +test("repeated Send clicks before a render create only one agent", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentCount(1); +}); + +test("retrying the app's agent creation returns the same agent for every attempt", async ({ + creation, + agentRetries, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start exactly one agent for this prompt."); + expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1); + await creation.expectAgentCount(1); +}); + +test("the first prompt still names an agent created with a receipt", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Name this new agent from its first prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentTitle("Name this new agent from its first prompt."); +}); + +test("reconnecting after a lost creation response recovers the agent and initial message", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("a remounted draft reconciles its original creation after reconnect", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await creation.evictAndReturnToDraft(); + await creation.expectPromptVisible("Deliver this initial prompt once."); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("repeated new-workspace prompt submissions create one workspace and one agent", async ({ + creation, +}) => { + await creation.openWorkspaceForm("local"); + await creation.submitRepeatedly("Create", "Start one agent in one new workspace."); + await creation.expectPromptVisible(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +test("separate drafts can intentionally create two agents in the same workspace", async ({ + creation, + delayedCreation, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start the first agent."); + await creation.expectPromptVisible("Start the first agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + await creation.startAnotherDraft(); + await creation.submitPrompt("Start the second agent."); + await creation.expectPromptVisible("Start the second agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + delayedCreation.release(); + await creation.expectAgentCount(2); +}); + +test("new workspace navigation and optimistic prompt precede agent initialization", async ({ + creation, + startup, + delayedCreation, +}) => { + await creation.openWorkspaceForm("worktree"); + await creation.submitPrompt("Show this prompt while the agent is starting.", "Create"); + await creation.expectAgentStillStarting(); + delayedCreation.expectSingleWorkspaceIntent(); + delayedCreation.release(); + await startup.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +for (const scenario of [ + { suffix: "", prepare: async () => {} }, + { + suffix: " after remount", + prepare: async (creation: Awaited>) => + creation.evictAndReturnToDraft(), + }, +]) { + test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({ + creation, + startup, + }) => { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Retry this workspace and agent together.", "Create"); + await creation.expectAgentStillStarting(); + await startup.fail(); + await creation.expectStartupFailure(); + await creation.expectOneCreatedWorkspace(); + await scenario.prepare(creation); + await creation.submitPrompt("Retry this workspace and agent together."); + await creation.expectAgentCount(1); + }); +} diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 327666ecb8..956340db74 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,9 +1,10 @@ -import { expect, test, type Page } from "../support/fixtures"; +import { expect, type Page } from "../support/fixtures"; +import { test } from "../support/creation-fixtures"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { openGlobalNewWorkspaceComposer, @@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - }; -} - -function parseWebSocketJson(message: WebSocketMessage): unknown { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(rawMessage); - } catch { - return null; - } -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise { const client = await connectDaemonClient({ clientIdPrefix: "codex-mode-preferences", @@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise { await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 }); } -async function recordAndBlockCreateAgentRequests(page: Page): Promise<{ - waitForCreateAgentRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const createAgentSeen = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - - server.onMessage((message) => { - ws.send(message); - }); - }); - - return { - waitForCreateAgentRequest: () => createAgentSeen, - }; -} - test.describe("New workspace Codex mode preferences", () => { test.describe.configure({ timeout: 240_000 }); test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, + startup, }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); - const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false }); try { await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); await gotoAppShell(page); @@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => { ).toBeVisible(); await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally."); - const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest(); + const createAgentRequest = await createAgentRecorder.waitForRequest(); + await expect(page).toHaveURL(/\/workspace\//); + await expect( + page.getByText("Keep Codex full access selected globally.", { exact: true }).first(), + ).toBeVisible(); expect(createAgentRequest.config).toMatchObject({ provider: "codex", modeId: "full-access", @@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => { await expect .poll(() => readCodexModePreference(page), { timeout: 10_000 }) .toBe("full-access"); + await startup.fail(); + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts new file mode 100644 index 0000000000..67a9528b05 --- /dev/null +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -0,0 +1,92 @@ +import { writeFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { copyPluginExample } from "./helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; +import { test as base } from "./fixtures"; +import { + createCreationScenario, + createPromptRetryScenario, + retryNextAgentCreation, +} from "./helpers/creation"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; + +export const test = base.extend<{ + startup: { release(): Promise; fail(): Promise }; + creation: Awaited>; + promptRetry: ReturnType; + agentRetries: Awaited>; + delayedCreation: Awaited>; +}>({ + startup: async ({ page }, provide) => { + void page; + const client = await connectNewWorkspaceDaemonClient(); + const plugin = await copyPluginExample("lifecycle-logger"); + const gate = path.join(plugin.directory, "startup.txt"); + const previous = await client.getDaemonConfig(); + try { + await writeFile(gate, "hold"); + await writeFile( + path.join(plugin.directory, "index.server.ts"), + ` +import { readFile, rm } from "node:fs/promises"; +export default function contribute(server) { + return server.before("agent.session_open", async ({ request }) => { + if (request.reason !== "create") return request; + const gate = ${JSON.stringify(gate)}; + let command = await readFile(gate, "utf8").catch(() => "release"); + while (command === "hold") { + await new Promise(resolve => setTimeout(resolve, 20)); + command = await readFile(gate, "utf8").catch(() => "release"); + } + if (command === "fail") { + await rm(gate, { force: true }); + throw new Error("Creation startup failed for test"); + } + return request; + }); +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + await provide({ + release: () => rm(gate, { force: true }), + fail: () => writeFile(gate, "fail"), + }); + } finally { + await rm(gate, { force: true }); + await client.removePlugin("lifecycle-logger"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await plugin.cleanup(); + await client.close(); + } + }, + creation: async ({ page }, provide) => { + const creation = await createCreationScenario(page); + try { + await provide(creation); + } finally { + await creation.cleanup(); + } + }, + promptRetry: async ({ page }, provide) => { + const gate = await installDaemonWebSocketGate(page); + try { + await provide(createPromptRetryScenario(page, gate)); + } finally { + gate.restore(); + } + }, + agentRetries: async ({ page }, provide) => { + await provide(await retryNextAgentCreation(page)); + }, + delayedCreation: async ({ page }, provide) => { + const delayed = await delayBrowserAgentCreatedStatus(page); + try { + await provide(delayed); + } finally { + delayed.release(); + } + }, +}); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 91b6867cf0..690a3067ae 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { loadSessionMessageReaders } from "./new-workspace"; import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; type WebSocketMessage = string | Buffer; @@ -321,6 +322,7 @@ export async function holdRewindCompletion( export async function delayCreatedAgentInitialTailResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); let createdAgentId: string | null = null; let releaseRequested = false; let delayedResponseSeen = false; @@ -350,19 +352,24 @@ export async function delayCreatedAgentInitialTailResponse( }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = sessionMessage ? getPayload(sessionMessage) : null; - if (sessionMessage?.type === "status" && payload?.status === "agent_created") { - const agentId = payload.agentId; - if (typeof agentId === "string") { - createdAgentId = agentId; - resolveCreatedAgent?.(agentId); - } + const sessionMessage = frames.server(message); + let createdId: unknown; + if (sessionMessage?.type === "agent.create.response") { + createdId = sessionMessage.payload.agent?.id; + } else if ( + sessionMessage?.type === "status" && + sessionMessage.payload.status === "agent_created" + ) { + createdId = sessionMessage.payload.agentId; + } + if (typeof createdId === "string") { + createdAgentId = createdId; + resolveCreatedAgent?.(createdId); } if (sessionMessage?.type === "fetch_agent_timeline_response") { - const agentId = payload?.agentId; - const direction = payload?.direction; + const agentId = sessionMessage.payload.agentId; + const direction = sessionMessage.payload.direction; if ( !delayedResponseSeen && typeof agentId === "string" && diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts new file mode 100644 index 0000000000..c29bbeeeeb --- /dev/null +++ b/packages/app/e2e/support/helpers/creation.ts @@ -0,0 +1,277 @@ +import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; +import { expect, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; +import { gotoAppShell } from "./app"; +import { gotoWorkspace } from "./launcher"; +import { fillComposerDraft } from "./composer"; +import { createAgentTabFromMenu } from "./workspace-tabs"; +import { + openNewWorkspaceComposer, + selectWorkspaceIsolation, + loadSessionMessageReaders, +} from "./new-workspace"; +import { seedWorkspace } from "./seed-client"; +import { + waitForSidebarHydration, + switchWorkspaceViaSidebar, + workspaceDeckEntryLocator, +} from "./workspace-ui"; +import { getServerId } from "./server-id"; +import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; +import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; + +/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */ +export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) { + const frames = await loadSessionMessageReaders(); + type AgentIntent = NonNullable; + let resolveRequest!: (agent: AgentIntent) => void; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((frame) => { + const message = frames.client(frame); + if (message?.type === "workspace.create.request" && message.agent) { + resolveRequest(message.agent); + if (options.block) return; + } + server.send(frame); + }); + server.onMessage((frame) => ws.send(frame)); + }); + return { waitForRequest: () => request }; +} + +export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise { + const create = page.getByRole("button", { name, exact: true }); + await expect(create).toBeEnabled(); + // Dispatch the queued clicks in one JS task, before pending state can paint. + // Exercise DOM events rather than calling the app's submit handler directly. + await create.evaluate((button) => { + for (let click = 0; click < 3; click++) { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + }); +} + +export async function observeCreationRequests(page: Page) { + const frames = await loadSessionMessageReaders(); + const pending = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const request = frames.client(payload); + if ( + request?.type === "workspace.create.request" || + request?.type === "agent.create.request" + ) { + pending.add(request.requestId); + } + }); + socket.on("framereceived", ({ payload }) => { + const response = frames.server(payload); + if ( + response?.type === "workspace.create.response" || + response?.type === "agent.create.response" || + (response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed")) + ) { + const requestId = response.payload.requestId; + if (typeof requestId === "string") pending.delete(requestId); + } + }); + }); + return { + async settled() { + await expect.poll(() => pending.size).toBe(0); + }, + }; +} + +export async function retryNextAgentCreation(page: Page) { + const frames = await loadSessionMessageReaders(); + const retryIds = new Set(); + const results: Array<{ status: string; agentId?: string }> = []; + let repeated = false; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + const request = frames.client(frame); + if (!repeated && request?.type === "agent.create.request") { + repeated = true; + for (let attempt = 1; attempt <= 3; attempt++) { + const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`; + retryIds.add(requestId); + // Keep the app's operation key and payload; only RPC correlation changes. + server.send(JSON.stringify({ type: "session", message: { ...request, requestId } })); + } + return; + } + server.send(frame); + }); + server.onMessage((frame) => { + const response = frames.server(frame); + if ( + response?.type === "agent.create.response" && + retryIds.delete(response.payload.requestId) + ) { + results.push({ + status: response.payload.error ? "agent_create_failed" : "agent_created", + agentId: response.payload.agent?.id, + }); + } + browser.send(frame); + }); + }); + return { + async completedAgentIds() { + await expect.poll(() => results.length).toBe(3); + expect(results.map((result) => result.status)).toEqual([ + "agent_created", + "agent_created", + "agent_created", + ]); + return results.map((result) => result.agentId); + }, + }; +} + +export async function createCreationScenario(page: Page) { + const requests = await observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); + let workspaceId = project.workspaceId; + return { + cleanup: project.cleanup, + async openWorkspaceForm(isolation: "local" | "worktree") { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + }, + async openAgentDraft() { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + }, + async startAnotherDraft() { + await createAgentTabFromMenu(page); + }, + async submitPrompt(prompt: string, button = "Send message") { + await fillComposerDraft(page, prompt); + await page.getByRole("button", { name: button, exact: true }).click(); + }, + async submitRepeatedly(button: string, prompt?: string) { + if (prompt) await fillComposerDraft(page, prompt); + await pressSubmitBeforeTheNextRender(page, button); + }, + async expectPromptVisible(prompt?: string) { + const rows = page.getByTestId("user-message"); + await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible(); + }, + async expectWorkspaceReadyBeforeAgentCompletion() { + await expect(page).toHaveURL(/\/workspace\//); + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }), + ).toBeVisible(); + }, + async expectAgentStillStarting() { + await this.expectWorkspaceReadyBeforeAgentCompletion(); + expect((await project.client.fetchAgents()).entries).toHaveLength(0); + }, + async expectStartupFailure() { + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); + }, + async expectOneCreatedWorkspace() { + await expect(page).toHaveURL(/\/workspace\//); + await requests.settled(); + const workspaces = (await project.client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ); + expect(workspaces).toHaveLength(1); + workspaceId = workspaces[0]!.id; + }, + async expectAgentCount(count: number) { + await requests.settled(); + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.filter( + ({ agent }) => agent.workspaceId === workspaceId, + ).length, + ) + .toBe(count); + }, + async expectAgentTitle(title: string) { + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.find( + ({ agent }) => agent.workspaceId === workspaceId, + )?.agent.title, + ) + .toBe(title); + }, + async evictAndReturnToDraft() { + const draft = page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }) + .first(); + const draftTestId = await draft.getAttribute("data-testid"); + expect(draftTestId).not.toBeNull(); + for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) { + const result = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + }); + if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace"); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: result.workspace.id, + }); + } + await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); + await page + .getByTestId(draftTestId!) + .filter({ visible: true }) + .click({ position: { x: 12, y: 13 } }); + }, + }; +} + +export function createPromptRetryScenario( + page: Page, + gate: Awaited>, +) { + let firstMessage: ReturnType[number] | undefined; + return { + holdAcknowledgement() { + gate.holdNextServerMessage("agent.create.response"); + }, + async waitForDeliveredPrompt() { + await gate.waitForHeldServerMessage("agent.create.response"); + firstMessage = gate.getClientRequests("agent.create.request").at(-1); + }, + async disconnectAndReconnect() { + const fetches = gate.getClientRequestCount("fetch_agents_request"); + await gate.drop(); + gate.restore(); + await expect + .poll(() => gate.getClientRequestCount("fetch_agents_request")) + .toBeGreaterThan(fetches); + }, + async expectSameAgentAndMessage() { + expect(gate.getClientRequestCount("agent.create.request")).toBe(1); + await expect + .poll(() => gate.getClientRequestCount("creation.subscribe.request")) + .toBeGreaterThan(0); + expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage); + expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0); + await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts index b36bbae32a..eee8cdaffb 100644 --- a/packages/app/e2e/support/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -64,3 +64,10 @@ function loadAppVersion(): string { } return packageJson.version; } + +export async function loadProtocolSchemas(): Promise { + const moduleUrl = pathToFileURL( + path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"), + ).href; + return import(moduleUrl); +} diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 2f3d6b22c6..7c9e212c93 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -21,6 +21,7 @@ interface ClientRequest { mode?: unknown; path?: unknown; agentId?: unknown; + messageId?: unknown; text?: unknown; activeTurnBehavior?: unknown; } diff --git a/packages/app/e2e/support/helpers/new-workspace-navigation.ts b/packages/app/e2e/support/helpers/new-workspace-navigation.ts index 6e6220c581..1b3106bdf7 100644 --- a/packages/app/e2e/support/helpers/new-workspace-navigation.ts +++ b/packages/app/e2e/support/helpers/new-workspace-navigation.ts @@ -134,15 +134,20 @@ export async function verifyDelayedWorkspaceCreation( ); if (!upload) throw new Error("The create request did not contain the submitted file"); expect(await readFile(upload.path)).toEqual(CONTEXT.buffer); + const agents = await client.fetchAgents(); + expect( + agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent, + ).toMatchObject({ + cwd: created.workspaceDirectory, + }); expect(delay.agentRequests[0]).toMatchObject({ - workspaceId: created.id, initialPrompt: PROMPT, config: { provider: "mock", model: "ten-second-stream", modeId: "load-test", thinkingOptionId: "low", - cwd: created.workspaceDirectory, + cwd: repo.path, }, attachments: [ expect.objectContaining({ diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 5cebc309b1..c6695a268d 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -1,8 +1,8 @@ import { expect, type BrowserContext, type Page } from "@playwright/test"; -import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { connectDaemonClient } from "./daemon-client-loader"; +import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; @@ -535,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown { } } -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - -function getStringField(input: Record, key: string): string | null { - const value = input[key]; - return typeof value === "string" ? value : null; +export async function loadSessionMessageReaders() { + // Use the same ESM module instance as the dynamically loaded daemon client. + const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas(); + return { + client(message: WebSocketMessage) { + const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + server(message: WebSocketMessage) { + const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + }; } export interface AgentCreatedDelayControl { release(): void; waitForCreateRequest(): Promise; waitForDelayedCreatedStatus(): Promise; + expectSingleWorkspaceIntent(): void; } export async function delayBrowserAgentCreatedStatus( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); const createRequestIds = new Set(); + const creationRequests: SessionInboundMessage[] = []; const delayedForwards: Array<() => void> = []; let releaseRequested = false; let resolveCreateRequest: (() => void) | null = null; @@ -581,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "send_agent_message_request") + creationRequests.push(sessionMessage); + if ( + sessionMessage?.type === "create_agent_request" || + sessionMessage?.type === "agent.create.request" || + sessionMessage?.type === "workspace.create.request" + ) { + creationRequests.push(sessionMessage); + createRequestIds.add(sessionMessage.requestId); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "status" && typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) { + const sessionMessage = frames.server(message); + const isCreated = + (sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") || + sessionMessage?.type === "agent.create.response" || + sessionMessage?.type === "workspace.create.response"; + if ( + isCreated && + typeof sessionMessage.payload.requestId === "string" && + createRequestIds.has(sessionMessage.payload.requestId) + ) { resolveDelayedCreatedStatus?.(); if (releaseRequested) { ws.send(message); @@ -621,26 +625,41 @@ export async function delayBrowserAgentCreatedStatus( forward(); } }, + + expectSingleWorkspaceIntent() { + expect(creationRequests).toHaveLength(1); + expect(creationRequests[0]).toMatchObject({ + type: "workspace.create.request", + agent: { initialPrompt: expect.any(String) }, + }); + }, waitForCreateRequest: () => createRequestSeen, waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } +type AgentCreationIntent = Pick< + CreateAgentRequestMessage, + "config" | "initialPrompt" | "attachments" +>; + export interface WorkspaceCreatedDelayControl { - agentRequests: readonly CreateAgentRequestMessage[]; + agentRequests: readonly AgentCreationIntent[]; release(): void; waitForCreateRequest(): Promise; } /** - * Holds `workspace.create.response` so a test can act as the user does while the daemon is still + * Holds workspace readiness events and responses so a test can act while the daemon is still * running `git worktree add` — most importantly, navigate somewhere else. */ export async function delayBrowserWorkspaceCreatedResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); - const agentRequests: CreateAgentRequestMessage[] = []; + const agentRequests: AgentCreationIntent[] = []; + const creationKeys = new Set(); const createRequestIds = new Set(); const delayedForwards: Array<() => void> = []; let releaseRequested = false; @@ -653,29 +672,25 @@ export async function delayBrowserWorkspaceCreatedResponse( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") - agentRequests.push(sessionMessage as CreateAgentRequestMessage); + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage); if (sessionMessage?.type === "workspace.create.request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + createRequestIds.add(sessionMessage.requestId); + if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey); + if (sessionMessage.agent) agentRequests.push(sessionMessage.agent); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "workspace.create.response" && - typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (requestId && createRequestIds.has(requestId) && !releaseRequested) { + const sessionMessage = frames.server(message); + const isCreation = + (sessionMessage?.type === "workspace.create.response" && + createRequestIds.has(sessionMessage.payload.requestId)) || + (sessionMessage?.type === "workspace.create.update" && + creationKeys.has(sessionMessage.payload.idempotencyKey)); + if (isCreation && !releaseRequested) { delayedForwards.push(() => ws.send(message)); return; } diff --git a/packages/app/e2e/support/helpers/workspace.ts b/packages/app/e2e/support/helpers/workspace.ts index 23bfc4bf9b..14b31be926 100644 --- a/packages/app/e2e/support/helpers/workspace.ts +++ b/packages/app/e2e/support/helpers/workspace.ts @@ -18,12 +18,11 @@ export interface TempDirectory { } /** - * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → - * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows - * `/tmp` doesn't exist, so fall back to the OS temp dir. + * Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture + * paths match the daemon's resolved paths. */ export async function resolveTempRoot(): Promise { - return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); + return await realpath(tmpdir()); } async function configureRemote(input: { diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 968b721eb2..4b2da86ea5 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,9 +1,10 @@ +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; @@ -32,7 +33,6 @@ import type { DaemonClient, } from "@getpaseo/client/internal/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; @@ -85,20 +85,23 @@ function buildChatDraftComposerArgs({ async function callWorkspaceCreation({ creationMethod, connectedClient, + creationId, + worktreeSlug, input, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; - input: { cwd: string }; + creationId: string; + worktreeSlug: string; + input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] }; }) { - if (creationMethod === "create_worktree") { - return connectedClient.createPaseoWorktree({ - cwd: input.cwd, - worktreeSlug: createNameId(), - }); - } return connectedClient.createWorkspace({ - source: { kind: "directory", path: input.cwd }, + idempotencyKey: creationId, + agent: input.agent, + source: + creationMethod === "create_worktree" + ? { kind: "worktree", cwd: input.cwd, worktreeSlug } + : { kind: "directory", path: input.cwd }, }); } @@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + }, [pendingWorkspaceSetup?.creationId]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -249,18 +252,25 @@ export function WorkspaceSetupDialog() { }, [client, isConnected, t]); const ensureWorkspace = useCallback( - async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + async (input: { + cwd: string; + attachments: MessagePayload["attachments"]; + agent?: CreateWorkspaceRequestOptions["agent"]; + onAgentCreated?: (agent: AgentSnapshotPayload) => void; + }) => { if (!pendingWorkspaceSetup) { throw new Error(t("workspaceSetup.errors.pendingRequired")); } - if (createdWorkspace) { + if (createdWorkspace && !input.agent) { return createdWorkspace; } const connectedClient = withConnectedClient(); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, + creationId: pendingWorkspaceSetup.creationId, + worktreeSlug: pendingWorkspaceSetup.worktreeSlug, connectedClient, input, }); @@ -271,6 +281,7 @@ export function WorkspaceSetupDialog() { ); } + if (payload.agent) input.onAgentCreated?.(payload.agent); const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); if (pendingWorkspaceSetup.creationMethod === "open_project") { @@ -307,8 +318,7 @@ export function WorkspaceSetupDialog() { try { setPendingAction("chat"); setErrorMessage(null); - const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); - const connectedClient = withConnectedClient(); + if (!composerState) { throw new Error(t("workspaceSetup.errors.composerStateRequired")); } @@ -322,21 +332,30 @@ export function WorkspaceSetupDialog() { }), }); const encodedImages = await encodeImages(wirePayload.images); - const workspaceDirectory = requireWorkspaceDirectory({ - workspaceId: ensuredWorkspace.id, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, + if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired")); + let createdAgent: AgentSnapshotPayload | undefined; + const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({ + composerState, + text, + attachments: wirePayload.attachments, + encodedImages: encodedImages ?? null, + workspaceDirectory: cwd, + workspaceId: "", + provider: composerState.selectedProvider, }); - const agent = await connectedClient.createAgent( - buildCreateAgentOptions({ - composerState, - text, - attachments: wirePayload.attachments, - encodedImages: encodedImages ?? null, - workspaceDirectory, - workspaceId: ensuredWorkspace.id, - provider: composerState.selectedProvider, - }), - ); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + attachments, + agent: { + ...agentInput, + clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`, + }, + onAgentCreated: (value) => { + createdAgent = value; + }, + }); + if (!createdAgent) throw new Error("The daemon did not create the requested agent"); + const agent = createdAgent; if (!getIsStillActive()) { return; @@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, ensureWorkspace, t, toast, - withConnectedClient, supportsForgeSearch, ], ); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..bd4d0fe95c 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,12 +5,11 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, - generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; @@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( + const [localMachine, dispatch] = useReducer( reducer, initialAttempt, (attempt): DraftAgentMachineState => @@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow({ }, ); + const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); + // Remounts can precede model hydration. Rebuild the preview when its inputs + // arrive, and observe the original request's failure through shared state. + const machine = useMemo>(() => { + if (pending?.lifecycle === "abandoned") { + return { tag: "draft", errorMessage: pending.errorMessage ?? "" }; + } + if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) { + return prepareCreateAttempt(initialAttempt, buildDraftAgent); + } + return localMachine; + }, [pending, localMachine, initialAttempt, buildDraftAgent]); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; @@ -233,14 +244,16 @@ export function useDraftAgentCreateFlow({ const resolved = error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); + markPendingCreateLifecycle({ + draftId, + lifecycle: "abandoned", + errorMessage: resolved.message, + }); onCreateError?.(resolved); throw error; } }, [ - clearPendingCreateAttempt, createRequest, draftId, getPendingServerId, @@ -255,7 +268,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { + const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + if ( + isSubmitting || + isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) + ) { throw new Error(t("composer.errors.alreadyLoading")); } @@ -296,10 +313,10 @@ export function useDraftAgentCreateFlow({ } const attempt: CreateAttempt = { - clientMessageId: generateMessageId(), + clientMessageId: `${draftId}:initial-message`, text: trimmedPrompt, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(images.length > 0 ? { images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 742380789f..0457dfb36f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -25,7 +25,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -137,6 +137,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -196,14 +197,18 @@ async function submitDraftCreateRequest(input: { }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await requestWorkspaceDraftAgent(client, { + const imagesData = await encodeImages(images); + const options = { + idempotencyKey: input.draftId, config, workspaceId, - text, + initialPrompt: text, clientMessageId: attempt.clientMessageId, - ...(images ? { images } : {}), - ...(attachmentsArray ? { attachments: attachmentsArray } : {}), - }); + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), + }; + const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId]; + const result = creation ? await creation.retry(options) : await client.createAgent(options); return { agentId: result.id, @@ -390,10 +395,7 @@ export function WorkspaceDraftAgentTab({ ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); const initialCreateAttempt = useMemo(() => { - if (!pendingAutoSubmit || !pendingCreateAttempt) { - return null; - } - if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + if (!pendingCreateAttempt) { return null; } return { @@ -407,7 +409,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -487,8 +489,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => - submitDraftCreateRequest({ + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, attempt, text, images, @@ -501,7 +508,8 @@ export function WorkspaceDraftAgentTab({ composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), + }); + }, onCreateSuccess: ({ result }) => { clearDraftInput("sent"); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8adf2a188a..e097593db9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, @@ -25,8 +25,6 @@ import { } from "@getpaseo/protocol/agent-attention-notification"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; @@ -45,9 +43,7 @@ import { createInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; -import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import type { AttachmentMetadata } from "@/attachments/types"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [client], ); - const _createAgent = useCallback( - async ({ - config, - initialPrompt, - images, - attachments, - git, - worktreeName, - requestId, - }: { - config: AgentSessionConfig; - initialPrompt: string; - images?: AttachmentMetadata[]; - attachments?: AgentAttachment[]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - }) => { - if (!client) { - console.warn("[Session] createAgent skipped: daemon unavailable"); - return; - } - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - await client.createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }); - }, - [client], - ); - const _setAgentMode = useCallback( (agentId: string, modeId: string) => { if (!client) { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index a9baaf007d..ba858297f4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,9 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; @@ -70,7 +76,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { @@ -87,15 +92,10 @@ import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; -import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { - CreatePaseoWorktreeInput, - DaemonClient, -} from "@getpaseo/client/internal/daemon-client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -130,10 +130,7 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; -import { - captureWorkspaceDraftCleanup, - createWorkspaceAgentInBackground, -} from "./new-workspace/background-handoff"; +import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); @@ -767,6 +764,10 @@ type SubmitOutcome = "navigated" | "background"; interface SubmitDraftInput { clearConsumedDraft: () => void; + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -798,37 +799,22 @@ interface WorkspaceDraftSubmissionConfig { target: WorkspaceTabTarget; } -async function createAndMergeWorkspace(input: { - client: NonNullable>; - createInput: Parameters< - NonNullable>["createPaseoWorktree"] - >[0]; - mergeWorkspaces: ( - serverId: string, - workspaces: ReturnType[], - ) => void; - serverId: string; - createFailedMessage: string; -}): Promise> { - const payload = await input.client.createPaseoWorktree(input.createInput); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? input.createFailedMessage); - } - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - const workspaceForInitialMerge = input.createInput.firstAgentContext - ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } - : normalizedWorkspace; - input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; +interface WorkspaceCreationResult { + workspace: ReturnType; + agent?: AgentSnapshotPayload; } async function createMultiplicityWorkspace(input: { + idempotencyKey: string; + worktreeSlug: string; client: NonNullable>; isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; prompt: string; attachments: AgentAttachment[]; mergeWorkspaces: ( @@ -837,7 +823,7 @@ async function createMultiplicityWorkspace(input: { ) => void; serverId: string; createFailedMessage: string; -}): Promise> { +}): Promise { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; @@ -846,12 +832,15 @@ async function createMultiplicityWorkspace(input: { attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + idempotencyKey: input.idempotencyKey, + agent: input.agent, + onEvent: input.onEvent, source: isWorktree ? { kind: "worktree", cwd: input.sourceDirectory, projectId, - worktreeSlug: createNameId(), + worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, } : { @@ -869,7 +858,7 @@ async function createMultiplicityWorkspace(input: { ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } : normalizedWorkspace; input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; + return { workspace: normalizedWorkspace, agent: payload.agent }; } interface CreateChatAgentInput { @@ -881,7 +870,9 @@ interface CreateChatAgentInput { prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; - }) => Promise>; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; + }) => Promise; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -945,7 +936,18 @@ function buildComposerInitialValues(input: { return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +const pendingWorkspaceSubmissions = new Map>(); +function runCreateChatAgent(input: CreateChatAgentInput): Promise { + const key = JSON.stringify([input.serverId, input.draftId]); + const pending = pendingWorkspaceSubmissions.get(key); + if (pending) return pending; + const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input)); + pendingWorkspaceSubmissions.set(key, submission); + void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined); + return submission; +} + +async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; @@ -963,36 +965,86 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise = { + config: { + provider, + cwd, + modeId: composerState.selectedMode || undefined, + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, + }, + initialPrompt: text, + clientMessageId: `${input.draftId}:initial-message`, + images: images?.length ? images : undefined, + attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined, + }; + const execute = async (requestedAgent = initialAgent): Promise => { + const { agent } = await ensureWorkspace({ + cwd, + prompt: text, + attachments: workspaceNamingAttachments, + withInitialAgent: true, + agent: requestedAgent, + onEvent: (snapshot) => { + if (!snapshot.workspace || navigated) return; + navigated = true; + if (!input.isStillOnCreateScreen()) return; + const workspace = normalizeWorkspaceDescriptor(snapshot.workspace); + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [ + { ...workspace, status: "running" }, + ]); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: workspace.workspaceDirectory, + provider, + composerState, + }); + outcome = submitWorkspaceDraft({ + clearConsumedDraft, + draftKey: input.draftKey, + draftContextScopeKey: input.draftContextScopeKey, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, + serverId, + clearDraft, + draftId: input.draftId, + initialSetup, + workspaceId: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, + text, + attachments, + provider, + composerState, + supportsForgeSearch: input.supportsForgeSearch, + agentCreation, + }); + }, + }); + if (!agent) throw new Error("Workspace creation returned no agent"); + return agent; + }; + const agentCreation = { + result: Promise.resolve().then(() => execute()), + retry: (request: CreateAgentRequestOptions) => + execute({ + ...initialAgent, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initialAgent.clientMessageId, + images: request.images, + attachments: request.attachments, + }), + }; + await agentCreation.result; + if (outcome === "background") clearConsumedDraft(); + return outcome; } function buildComposerConfig(input: { @@ -1054,7 +1106,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { +function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { const { serverId, clearDraft, @@ -1068,7 +1120,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise - requestWorkspaceDraftAgent(input.resolveClient(), { - workspaceId, - config: buildWorkspaceDraftAgentConfig({ - provider: submission.provider, - cwd: submission.cwd, - ...(submission.modeId ? { modeId: submission.modeId } : {}), - ...(submission.model ? { model: submission.model } : {}), - ...(submission.thinkingOptionId - ? { thinkingOptionId: submission.thinkingOptionId } - : {}), - ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), - }), - text: text.trim(), - clientMessageId, - ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), - ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), - }), - }); - return "background"; - } - - useCreateFlowStore.getState().setPending({ + const started = useCreateFlowStore.getState().trySetPending({ serverId, draftId, workspaceId, @@ -1120,6 +1145,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise 0 ? { images: wirePayload.images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); + if (!started) return "navigated"; useWorkspaceDraftSubmissionStore.getState().setPending({ serverId, workspaceId, @@ -1135,6 +1161,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); + const [creationIdentity] = useState(() => ({ + draftId: draftId ?? generateDraftId(), + worktreeSlug: createNameId(), + })); const [errorMessage, setErrorMessage] = useState(null); - const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [creationResult, setCreationResult] = useState< + WorkspaceCreationResult | { workspace: null } + >({ workspace: null }); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); @@ -1696,7 +1727,7 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const workspace = createdWorkspace; + const { workspace } = creationResult; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1997,45 +2028,17 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(nextOpen); }, []); - const buildCreateWorktreeInput = useCallback( - (input: { - cwd: string; - prompt: string; - attachments: AgentAttachment[]; - checkoutRequest: PickerCheckoutRequest | undefined; - }): CreatePaseoWorktreeInput => { - if (!selectedProject) { - throw new Error("Choose a project"); - } - if (!selectedSourceDirectory) { - throw new Error("Choose a host for this project"); - } - const firstAgentContext = buildFirstAgentContext(input); - const hostProjectId = getHostProjectId(selectedProject, selectedServerId); - if (!hostProjectId) { - throw new Error("Project is not available on the selected host"); - } - - return { - cwd: selectedSourceDirectory, - projectId: hostProjectId, - worktreeSlug: createNameId(), - ...(firstAgentContext ? { firstAgentContext } : {}), - ...input.checkoutRequest, - }; - }, - [selectedProject, selectedServerId, selectedSourceDirectory], - ); - const ensureWorkspace = useCallback( async (input: { cwd: string; prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; }) => { - if (createdWorkspace) { - return createdWorkspace; + if (creationResult.workspace) { + return creationResult; } if (!selectedProject) { throw new Error("Choose a project"); @@ -2058,33 +2061,29 @@ export function NewWorkspaceScreen({ selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate), ) : undefined; - const normalizedWorkspace = supportsWorkspaceMultiplicity - ? await createMultiplicityWorkspace({ - client: connectedClient, - isolation: effectiveIsolation, - project: selectedProject, - sourceDirectory: selectedSourceDirectory, - checkoutRequest, - withInitialAgent: input.withInitialAgent, - prompt: input.prompt, - attachments: input.attachments, - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }) - : await createAndMergeWorkspace({ - client: connectedClient, - createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }); - setCreatedWorkspace(normalizedWorkspace); + const normalizedWorkspace = await createMultiplicityWorkspace({ + idempotencyKey: creationIdentity.draftId, + worktreeSlug: creationIdentity.worktreeSlug, + client: connectedClient, + isolation: createsWorktree ? "worktree" : "local", + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + checkoutRequest, + withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, + agent: input.agent, + onEvent: input.onEvent, + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); + setCreationResult(normalizedWorkspace); return normalizedWorkspace; }, [ - buildCreateWorktreeInput, - createdWorkspace, + creationIdentity, + creationResult, effectiveIsolation, mergeWorkspaces, queryClient, @@ -2109,7 +2108,7 @@ export function NewWorkspaceScreen({ let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, serverId: selectedServerId, navigate: (targetServerId, workspaceId) => { if (!isStillOnCreateScreen()) { @@ -2136,7 +2135,7 @@ export function NewWorkspaceScreen({ serverId: selectedServerId, clearDraft: chatDraft.clear, draftKey, - draftId, + draftId: creationIdentity.draftId, draftContextScopeKey, supportsForgeSearch, resolveClient: withConnectedClient, @@ -2159,7 +2158,7 @@ export function NewWorkspaceScreen({ [ composerState, draftContextScopeKey, - draftId, + creationIdentity, chatDraft.clear, draftKey, ensureWorkspace, @@ -2186,7 +2185,7 @@ export function NewWorkspaceScreen({ prompt: terminalPromptText, profile: selectedTerminalProfile, profileName: selectedTerminalProfile?.name, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, createTerminal: async (input) => { const connectedClient = withConnectedClient(); const createdTerminal = await connectedClient.createTerminal( diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index c1b4ec8d77..4b5d73bd15 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -6,6 +6,33 @@ describe("create-flow-store", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); + it("accepts a retry after failure while rejecting repeated active or completed submissions", () => { + const store = useCreateFlowStore.getState(); + const submission = { + draftId: "draft-retry", + serverId: "server-1", + workspaceId: "workspace-1", + agentId: null, + clientMessageId: "draft-retry:initial-message", + text: "hello", + timestamp: 1, + }; + expect(store.trySetPending(submission)).toBe(true); + expect(store.trySetPending(submission)).toBe(false); + store.markLifecycle({ + draftId: submission.draftId, + lifecycle: "abandoned", + errorMessage: "Disconnected", + }); + expect(store.trySetPending(submission)).toBe(true); + expect(useCreateFlowStore.getState().pendingByDraftId[submission.draftId]).toEqual({ + ...submission, + lifecycle: "active", + }); + store.markLifecycle({ draftId: submission.draftId, lifecycle: "sent" }); + expect(store.trySetPending(submission)).toBe(false); + }); + it("tracks lifecycle transitions explicitly", () => { const store = useCreateFlowStore.getState(); store.setPending({ diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 2056e42c0f..95a85137a5 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -13,13 +13,14 @@ export interface PendingCreateAttempt { text: string; timestamp: number; lifecycle: CreateFlowLifecycleState; + errorMessage?: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } export function isActiveCreateFlowForDraft(input: { pending: PendingCreateAttempt | null | undefined; - serverId: string; + serverId: string | null; draftId: string | null | undefined; }): boolean { const draftId = input.draftId?.trim(); @@ -34,15 +35,20 @@ export function isActiveCreateFlowForDraft(input: { interface CreateFlowState { pendingByDraftId: Record; setPending: (pending: Omit) => void; + trySetPending: (pending: Omit) => boolean; updateAgentId: (input: { draftId: string; agentId: string }) => void; - markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void; + markLifecycle: (input: { + draftId: string; + lifecycle: CreateFlowLifecycleState; + errorMessage?: string; + }) => void; rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: (input: { draftId: string }) => void; clearByAgent: (input: { serverId: string; agentId: string }) => void; clearAll: () => void; } -export const useCreateFlowStore = create((set) => ({ +export const useCreateFlowStore = create((set, get) => ({ pendingByDraftId: {}, setPending: (pending) => set((state) => ({ @@ -54,6 +60,12 @@ export const useCreateFlowStore = create((set) => ({ }, }, })), + trySetPending: (pending) => { + const existing = get().pendingByDraftId[pending.draftId]; + if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false; + get().setPending(pending); + return true; + }, updateAgentId: ({ draftId, agentId }) => set((state) => { const current = state.pendingByDraftId[draftId]; @@ -67,7 +79,7 @@ export const useCreateFlowStore = create((set) => ({ }, }; }), - markLifecycle: ({ draftId, lifecycle }) => + markLifecycle: ({ draftId, lifecycle, errorMessage }) => set((state) => { const current = state.pendingByDraftId[draftId]; if (!current || current.lifecycle === lifecycle) { @@ -76,7 +88,7 @@ export const useCreateFlowStore = create((set) => ({ return { pendingByDraftId: { ...state.pendingByDraftId, - [draftId]: { ...current, lifecycle }, + [draftId]: { ...current, lifecycle, errorMessage }, }, }; }), diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts new file mode 100644 index 0000000000..73e1a40899 --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { transformFileSync } from "@babel/core"; +import { expect, test } from "vitest"; +import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store"; + +// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug. +function loadProductionStore() { + const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url)); + const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false }; + const result = transformFileSync(filename, { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + envName: "production", + caller, + }); + if (!result?.code) throw new Error("Expo produced no store code"); + const module = { + exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore }, + }; + new Function("require", "module", "exports", result.code)( + createRequire(import.meta.url), + module, + module.exports, + ); + return module.exports.useWorkspaceDraftSubmissionStore; +} + +test("the production app consumes a draft once and leaves other drafts pending", () => { + const store = loadProductionStore(); + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-1", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + } as const; + store.getState().setPending({ ...submission, attachments: [] }); + store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" }); + expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull(); + expect(store.getState().consumePending(submission)).toEqual(submission); + expect(store.getState().consumePending(submission)).toBeNull(); + expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); +}); + +test("consuming draft presentation retains its running creation through remount and retry", () => { + const store = loadProductionStore(); + const result = new Promise(() => {}); + const creation = { result, retry: () => result }; + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-one", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + agentCreation: creation, + }; + store.getState().setPending(submission); + store.getState().consumePending(submission); + expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined(); + expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation); + store.getState().clearDraftSetup({ draftId: submission.draftId }); + expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined(); +}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 2dbd27f2a5..639701d740 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,9 +1,16 @@ +import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { + /** Already-running creation. Mounting the draft only observes its result. */ + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; workspaceId: string; draftId: string; @@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup { interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; setupByDraftId: Record; + creationByDraftId: Record>; setPending: (submission: PendingWorkspaceDraftSubmission) => void; setDraftSetup: (input: { draftId: string; @@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create ({ pendingByDraftId: {}, setupByDraftId: {}, + creationByDraftId: {}, setPending: (submission) => set((state) => ({ + creationByDraftId: submission.agentCreation + ? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation } + : state.creationByDraftId, pendingByDraftId: { ...state.pendingByDraftId, [submission.draftId]: submission, @@ -82,9 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create { - if (!state.setupByDraftId[normalizedDraftId]) return state; + const creationByDraftId = { ...state.creationByDraftId }; + delete creationByDraftId[normalizedDraftId]; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; - return { setupByDraftId }; + return { setupByDraftId, creationByDraftId }; }); }, consumePending: (input) => { @@ -96,8 +109,10 @@ export const useWorkspaceDraftSubmissionStore = create { sourceWorkspaceId: "42", displayName: "project", creationMethod: "open_project", + creationId: expect.any(String), + worktreeSlug: expect.any(String), }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 31e3bfc024..d0205ba27a 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,3 +1,5 @@ +import { createNameId } from "mnemonic-id"; +import { generateDraftId } from "@/stores/draft-keys"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -44,7 +46,9 @@ export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | n } interface WorkspaceSetupStoreState { - pendingWorkspaceSetup: PendingWorkspaceSetup | null; + pendingWorkspaceSetup: + | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) + | null; snapshots: Record; requestedKeys: Set; surfacedFailedSetupKeys: Set; @@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create()((set, g requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { - set({ pendingWorkspaceSetup: value }); + set({ + pendingWorkspaceSetup: { + ...value, + creationId: generateDraftId(), + worktreeSlug: createNameId(), + }, + }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); diff --git a/packages/client/src/creation/index.test.ts b/packages/client/src/creation/index.test.ts new file mode 100644 index 0000000000..40828d4bfb --- /dev/null +++ b/packages/client/src/creation/index.test.ts @@ -0,0 +1,234 @@ +import { expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationClient, type CreationResult } from "./index.js"; + +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; + +function fixture(modern: boolean) { + const requests: Array<{ kind: string; input: Record }> = []; + const legacy: Array<{ kind: string; input: unknown }> = []; + let resolve!: (result: CreationResult) => void; + const result = new Promise((done) => { + resolve = done; + }); + const client = new CreationClient({ + supports: () => modern, + requestId: () => "generated-key", + request: async (kind, input) => { + requests.push({ kind, input }); + return result; + }, + observe: () => () => {}, + legacyWorkspace: async (input) => { + legacy.push({ kind: "workspace", input }); + return { requestId: "legacy-workspace", workspace, error: null, setupTerminalId: null }; + }, + legacyAgent: async (input) => { + legacy.push({ kind: "agent", input }); + return agent; + }, + sendMessage: async (id, text, options) => { + legacy.push({ kind: "message", input: { id, text, ...options } }); + }, + }); + const input = { + idempotencyKey: "intent-one", + source: { kind: "directory" as const, path: "/project" }, + agent: { + config: { provider: "codex", cwd: "/project" }, + initialPrompt: "Start once", + clientMessageId: "message-one", + }, + }; + return { client, requests, legacy, resolve, input }; +} + +test("duplicate client submissions join one complete intent and cumulative updates never regress", async () => { + const f = fixture(true); + const phases: string[] = []; + const first = f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + const duplicate = f.client.createWorkspace(f.input); + expect(duplicate).toBe(first); + expect(f.requests).toEqual([{ kind: "workspace", input: { ...f.input, subscribe: true } }]); + await expect( + f.client.createWorkspace({ + ...f.input, + agent: { ...f.input.agent, initialPrompt: "Different intent" }, + }), + ).rejects.toThrow("workspace_request_key_conflict"); + const snapshot: CreationSnapshot = { + kind: "workspace", + idempotencyKey: "intent-one", + revision: 2, + phase: "workspace_ready", + workspaceId: workspace.id, + agentId: agent.id, + workspace, + error: null, + }; + f.client.receive(snapshot); + f.client.receive({ ...snapshot, revision: 1, phase: "accepted" }); + expect(phases).toEqual(["workspace_ready"]); + const late: string[] = []; + f.client.createWorkspace({ ...f.input, onEvent: (update) => late.push(update.phase) }); + expect(late).toEqual(["workspace_ready"]); + f.resolve({ + workspace, + agent, + error: null, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await first).toMatchObject({ + workspace, + agent, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await duplicate).toEqual(await first); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("legacy adaptation keeps workspace, agent and initial prompt sequencing inside the client", async () => { + const f = fixture(false); + const phases: string[] = []; + const result = await f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + expect(result).toMatchObject({ workspace, agent, error: null }); + expect(phases).toEqual(["workspace_ready"]); + expect(f.legacy).toEqual([ + { kind: "workspace", input: { idempotencyKey: "intent-one", source: f.input.source } }, + { + kind: "agent", + input: { + config: { provider: "codex", cwd: workspace.workspaceDirectory }, + workspaceId: workspace.id, + idempotencyKey: "intent-one:agent", + clientMessageId: "message-one", + }, + }, + { + kind: "message", + input: { + id: agent.id, + text: "Start once", + messageId: "message-one", + images: undefined, + attachments: undefined, + }, + }, + ]); + expect(f.requests).toEqual([]); + f.client.close(); +}); + +test("legacy daemons cannot silently replace caller-selected IDs", async () => { + const f = fixture(false); + await expect(f.client.createWorkspace({ ...f.input, workspaceId: workspace.id })).rejects.toThrow( + "caller-selected", + ); + await expect( + f.client.createAgent({ agentId: agent.id, config: f.input.agent.config }), + ).rejects.toThrow("caller-selected"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("keyed creation with an empty prompt works on legacy daemons without sending an empty message", async () => { + const f = fixture(false); + await f.client.createAgent({ + idempotencyKey: "empty-agent", + config: f.input.agent.config, + initialPrompt: "", + }); + expect(f.legacy).toEqual([ + { kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } }, + ]); + f.client.close(); +}); + +test.each([ + ["/repo/project", "/repo/project/../outside"], + ["C:\\repo\\project", "C:\\repo\\project\\..\\outside"], +])("legacy creation rejects escaping %s before creating a workspace", async (source, cwd) => { + const f = fixture(false); + await expect( + f.client.createWorkspace({ + ...f.input, + source: { kind: "directory", path: source }, + agent: { ...f.input.agent, config: { provider: "codex", cwd } }, + }), + ).rejects.toThrow("inside the workspace source"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test.each([ + { source: { kind: "directory" as const, path: "/project" }, cwd: "/project/src" }, + { source: { kind: "directory" as const, path: "C:\\project" }, cwd: "C:\\project\\src" }, + { source: { kind: "worktree" as const, projectId: "project" }, cwd: "/project/src" }, +])("legacy creation preserves the agent subdirectory for $source", async ({ source, cwd }) => { + const f = fixture(false); + await f.client.createWorkspace({ + ...f.input, + source, + agent: { config: { provider: "codex", cwd } }, + }); + expect(f.legacy[1]).toMatchObject({ + kind: "agent", + input: { workspaceId: workspace.id, config: { cwd: "/project/worktree/src" } }, + }); + f.client.close(); +}); diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts new file mode 100644 index 0000000000..4ae64c34ff --- /dev/null +++ b/packages/client/src/creation/index.ts @@ -0,0 +1,276 @@ +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceCreateResponse, +} from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestOptions, CreateWorkspaceRequestOptions } from "../daemon-client.js"; + +type Kind = CreationSnapshot["kind"]; +type Observer = (snapshot: CreationSnapshot) => void; +export interface CreationResult { + agent?: AgentSnapshotPayload | null; + workspace?: WorkspaceCreateResponse["payload"]["workspace"]; + creation?: CreationSnapshot; + error: string | null; + errorCode?: string; + setupTerminalId?: string | null; + setupSkippedReason?: string; + requestId?: string; +} +interface Dependencies { + supports: () => boolean; + requestId: () => string; + request: (kind: Kind, input: Record) => Promise; + observe: ( + kind: Kind, + key: string, + next: (snapshot: CreationSnapshot | null) => void, + error: (error: unknown) => void, + ) => () => void; + legacyAgent: (input: CreateAgentRequestOptions) => Promise; + legacyWorkspace: ( + input: CreateWorkspaceRequestOptions, + ) => Promise; + sendMessage: ( + id: string, + text: string, + options: { + messageId: string; + images?: CreateAgentRequestOptions["images"]; + attachments?: CreateAgentRequestOptions["attachments"]; + }, + ) => Promise; +} +interface Operation { + kind: Kind; + key: string; + fingerprint: string; + input: Record; + observers: Set; + snapshot?: CreationSnapshot; + result: Promise; + resolve: (result: CreationResult) => void; + reject: (error: Error) => void; + settled: boolean; + modern: boolean; + recovering: boolean; + stopObserving?: () => void; +} + +/** Client-owned observation and compatibility. UI callbacks never advance creation. */ +export class CreationClient { + private readonly operations = new Map(); + constructor(private readonly deps: Dependencies) {} + + createAgent(input: CreateAgentRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("agent", request, onEvent, () => this.legacyAgent(request)); + } + createWorkspace(input: CreateWorkspaceRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("workspace", request, onEvent, async (operation) => { + if (request.workspaceId || request.agent?.agentId) + throw new Error("Update the host to use caller-selected creation IDs."); + const { agent, ...workspaceInput } = request; + const sourceCwd = + request.source.kind === "directory" ? request.source.path : request.source.cwd; + const relativeCwd = + agent && sourceCwd ? relativeDirectory(agent.config!.cwd, sourceCwd) : undefined; + const workspace = await this.deps.legacyWorkspace(workspaceInput); + if (workspace.error || !workspace.workspace) return workspace; + this.receive({ + kind: "workspace", + idempotencyKey: operation.key, + revision: 1, + phase: "workspace_ready", + workspaceId: workspace.workspace.id, + agentId: null, + workspace: workspace.workspace, + setupSkippedReason: workspace.setupSkippedReason, + error: null, + }); + if (!agent) return workspace; + const cwd = `${workspace.workspace.workspaceDirectory!.replace(/[\\/]+$/, "")}${ + relativeCwd ?? relativeDirectory(agent.config!.cwd, workspace.workspace.projectRootPath) + }`; + const created = await this.legacyAgent({ + ...agent, + config: { ...agent.config!, cwd }, + workspaceId: workspace.workspace.id, + idempotencyKey: `${operation.key}:agent`, + }); + return { ...workspace, agent: created.agent }; + }); + } + + private async legacyAgent(input: CreateAgentRequestOptions): Promise { + if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. + // Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK. + if (input.idempotencyKey) { + const { initialPrompt = "", images, attachments, ...creation } = input; + const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length); + if (hasPrompt && input.outputSchema) + throw new Error("Update the host to create a keyed agent with structured initial output."); + const agent = await this.deps.legacyAgent(creation); + if (hasPrompt) + await this.deps.sendMessage(agent.id, initialPrompt, { + messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`, + images, + attachments, + }); + return { agent, error: null }; + } + return { agent: await this.deps.legacyAgent(input), error: null }; + } + + private start( + kind: Kind, + input: Record, + observer: Observer | undefined, + legacy: (operation: Operation) => Promise, + ): Promise { + const key = + typeof input.idempotencyKey === "string" ? input.idempotencyKey : this.deps.requestId(); + const identity = JSON.stringify([kind, key]); + const { requestId: _requestId, ...intent } = input; + const fingerprint = JSON.stringify(intent, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); + const previous = this.operations.get(identity); + if (previous) { + if (previous.fingerprint !== fingerprint) + return Promise.reject(new Error(`${kind}_request_key_conflict`)); + if (observer) { + previous.observers.add(observer); + if (previous.snapshot) this.notify(observer, previous.snapshot); + } + return previous.result; + } + let resolve!: Operation["resolve"]; + let reject!: Operation["reject"]; + const result = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const operation: Operation = { + kind, + key, + input: { ...input, idempotencyKey: key }, + fingerprint, + observers: new Set(observer ? [observer] : []), + result, + resolve, + reject, + settled: false, + modern: this.deps.supports(), + recovering: false, + }; + this.operations.set(identity, operation); + if (operation.modern) void this.submit(operation); + else + void legacy(operation).then( + (value) => this.finish(operation, value), + (error) => this.fail(operation, error), + ); + return result; + } + + private async submit(operation: Operation): Promise { + try { + const result = await this.deps.request(operation.kind, { + ...operation.input, + subscribe: true, + }); + if (result.creation) this.receive(result.creation); + this.finish(operation, result); + } catch (error) { + if ( + operation.settled || + (error instanceof Error && "code" in error && error.code === "DAEMON_CONNECTION_LOST") + ) + return; + this.fail(operation, error); + } + } + receive(snapshot: CreationSnapshot): void { + const operation = this.operations.get(JSON.stringify([snapshot.kind, snapshot.idempotencyKey])); + if (!operation || (operation.snapshot && operation.snapshot.revision > snapshot.revision)) + return; + if (operation.snapshot?.revision !== snapshot.revision) { + operation.snapshot = snapshot; + for (const observer of operation.observers) this.notify(observer, snapshot); + } + if (operation.recovering && (snapshot.phase === "completed" || snapshot.phase === "failed")) { + this.finish(operation, { + agent: snapshot.agent, + workspace: snapshot.workspace, + creation: snapshot, + setupSkippedReason: snapshot.setupSkippedReason, + error: snapshot.error, + errorCode: snapshot.errorCode, + }); + } + } + reconnect(): void { + for (const operation of this.operations.values()) { + if (operation.settled || !operation.modern || operation.recovering) continue; + operation.recovering = true; + operation.stopObserving = this.deps.observe( + operation.kind, + operation.key, + (snapshot) => { + if (!snapshot) void this.submit(operation); + else { + // Re-subscription is authoritative even if its revision matches the last live event. + this.receive(snapshot); + } + }, + (error) => this.fail(operation, error), + ); + } + } + close(): void { + for (const operation of this.operations.values()) + if (!operation.settled) this.fail(operation, new Error("Daemon client closed")); + this.operations.clear(); + } + private finish(operation: Operation, result: CreationResult): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.resolve(result); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private fail(operation: Operation, error: unknown): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.reject(error instanceof Error ? error : new Error(String(error))); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch { + /* Observation does not control execution. */ + } + } +} + +function relativeDirectory(cwd: string, source: string): string { + const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = normalize(cwd), + root = normalize(source); + // Parent traversal must never survive mapping into the newly created checkout. + if ([normalized, root].some((path) => path.split("/").includes(".."))) + throw new Error("Agent directory must be inside the workspace source"); + if (normalized === root) return ""; + if (!normalized.startsWith(`${root}/`)) + throw new Error("Agent directory must be inside the workspace source"); + return normalized.slice(root.length); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 866f5bba22..4d36bb686d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -6323,3 +6323,169 @@ test("wire snapshot callers own expansion and receive hash references unchanged" ); expect(await request).toEqual(body); }); + +test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-contract", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } }); + await connected; + const creation = client.createAgent({ + idempotencyKey: "draft-one", + provider: "codex", + cwd: "/project", + workspaceId: "wks_0123456789abcdef", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "agent.create.request", + idempotencyKey: "draft-one", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); + expect(transport.sent).toHaveLength(1); +}); + +test("creation lifecycle acknowledgement reaches the observer before the final response", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-progress", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true } }); + await connected; + const phases: string[] = []; + const creation = client.createAgent({ + provider: "codex", + cwd: "/project", + idempotencyKey: "observe-one", + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.update", + payload: { + kind: "agent", + idempotencyKey: "observe-one", + revision: 0, + phase: "accepted", + workspaceId: null, + agentId: "00000000-0000-4000-8000-000000000001", + error: null, + }, + }), + ); + expect(phases).toEqual(["accepted"]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); +}); + +test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-reconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const features = { creationLifecycle: true, ownedSubscriptions: true }; + const connect = client.connect(); + transport.triggerOpen({ features }); + await connect; + const phases: string[] = []; + const creation = client.createWorkspace({ + idempotencyKey: "reconnect-one", + source: { kind: "directory", path: "/project" }, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const snapshot = { + kind: "workspace", + idempotencyKey: "reconnect-one", + phase: "accepted", + revision: 0, + workspaceId: "wks_0123456789abcdef", + agentId: null, + error: null, + }; + for (const subscriptionId of ["first-connection", "second-connection"]) { + transport.triggerClose(); + const reconnected = client.connect(); + transport.triggerOpen({ features }); + await reconnected; + await expect.poll(() => transport.sent.length).toBe(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "creation.subscribe.request", + idempotencyKey: "reconnect-one", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId, + snapshot, + error: null, + }, + }), + ); + } + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.update", + payload: { + ...snapshot, + subscriptionId: "second-connection", + revision: 1, + phase: "failed", + error: "Provisioning failed", + }, + }), + ); + expect(await creation).toMatchObject({ error: "Provisioning failed" }); + await expect.poll(() => transport.sent.length).toBe(2); + const release = parseSentFrame(transport.sent.at(-1)); + expect(release).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "second-connection", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "second-connection", + }, + }), + ); + expect(phases).toEqual(["accepted", "failed"]); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5c2e15d906..458ccf5c92 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6,6 +6,8 @@ import { DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, } from "./connection/index.js"; +import { CreationClient } from "./creation/index.js"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -370,6 +372,8 @@ export interface AgentAttentionRequiredNotification { type AgentConfigOverrides = Partial>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; @@ -392,6 +396,20 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { labels?: Record; } +export interface CreateWorkspaceRequestOptions { + source: WorkspaceCreateRequest["source"]; + title?: string; + idempotencyKey?: string; + workspaceId?: string; + agent?: Omit< + CreateAgentRequestOptions, + "workspaceId" | "onEvent" | "worktree" | "git" | "worktreeName" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; + firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; + requestId?: string; +} + export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, | "cwd" @@ -1416,6 +1434,7 @@ export class DaemonClient { this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); await this.owned.close(); + this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); @@ -2656,7 +2675,58 @@ export class DaemonClient { // Agent Lifecycle // ============================================================================ + private readonly creations = new CreationClient({ + supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true, + requestId: () => this.createRequestId(), + request: (kind, input) => + kind === "workspace" + ? this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "workspace.create.request" }, + responseType: "workspace.create.response", + timeout: 0, + }) + : this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "agent.create.request" }, + responseType: "agent.create.response", + timeout: 0, + }), + observe: (kind, idempotencyKey, next, error) => { + const observation = this.observe("creation.subscribe.response", { + type: "creation.subscribe.request", + kind, + idempotencyKey, + }); + observation.subscribe({ + snapshot: (result) => next(result.snapshot), + update: (message) => { + if (message.type === "workspace.create.update" || message.type === "agent.create.update") + next(message.payload); + }, + error, + }); + return () => { + void observation.release().catch(error); + }; + }, + legacyAgent: (input) => this.createLegacyAgent(input), + legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), + sendMessage: (id, text, options) => this.sendMessage(id, text, options), + }); + async createAgent(options: CreateAgentRequestOptions): Promise { + const result = await this.creations.createAgent({ + ...options, + config: resolveAgentConfig(options), + }); + if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); + return result.agent; + } + + private async createLegacyAgent( + options: CreateAgentRequestOptions, + ): Promise { if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -4334,18 +4404,43 @@ export class DaemonClient { } async createWorkspace( - input: { - source: WorkspaceCreateRequest["source"]; - title?: string; - firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; - }, + input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise { + const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + const result = await this.creations.createWorkspace({ + ...input, + requestId: resolvedRequestId, + ...(input.agent + ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } + : {}), + }); + return { + ...result, + workspace: result.workspace ?? null, + agent: result.agent ?? undefined, + setupTerminalId: result.setupTerminalId ?? null, + requestId: result.requestId ?? resolvedRequestId, + }; + } + + private async createLegacyWorkspace( + input: CreateWorkspaceRequestOptions, + requestId?: string, + ): Promise { + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. + if ( + input.idempotencyKey !== undefined && + !this.lastServerInfoMessage?.features?.workspaceRequestReceipts + ) { + throw new Error("Update the host to use retry-safe workspace creation."); + } return this.sendCorrelatedSessionRequest({ requestId, message: { type: "workspace.create.request", source: input.source, + ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } @@ -6276,6 +6371,7 @@ export class DaemonClient { ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, }); + this.creations.reconnect(); this.flushPendingSendQueue(); this.resolveConnect(); } @@ -6310,6 +6406,12 @@ export class DaemonClient { } } + if ( + consumerMessage.type === "workspace.create.update" || + consumerMessage.type === "agent.create.update" + ) { + if (!consumerMessage.payload.subscriptionId) this.creations.receive(consumerMessage.payload); + } this.resolveWaiters(consumerMessage); this.owned.receive(consumerMessage); } @@ -6493,6 +6595,15 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + agentId: _agentId, + onEvent: _onEvent, + idempotencyKey: _idempotencyKey, + clientMessageId: _clientMessageId, + callerAgentId: _callerAgentId, + outputSchema: _outputSchema, + attachments: _attachments, + worktree: _worktree, + autoArchive: _autoArchive, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8485b063af..e113d48667 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, + CreationSnapshot, CreateAgentRequestMessage, FetchWorkspacesRequestMessage, FetchWorkspacesResponseMessage, @@ -28,7 +29,7 @@ import type { WorkspaceDescriptorPayload, WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./daemon-client.js"; +import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js"; import { createTerminalActions, type PaseoTerminalActions, @@ -142,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions { requestId?: string; } -export type PaseoWorkspaceCreateOptions = Omit & { +export type PaseoWorkspaceCreateOptions = Omit< + WorkspaceCreateRequest, + "type" | "requestId" | "agent" | "subscribe" +> & { requestId?: string; + agent?: Omit< + PaseoAgentCreateOptions, + "worktree" | "git" | "onEvent" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; }; export interface PaseoWorkspaceArchiveResult { @@ -232,6 +241,9 @@ export interface PaseoAgentConfig { } export interface PaseoAgentCreateOptions { + idempotencyKey?: string; + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; parent?: string | PaseoAgentHandle; @@ -513,6 +525,29 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { }; } +function toDaemonAgentCreateOptions( + options: PaseoAgentCreateOptions, + placement?: { workspaceId: string; cwd: string }, +): CreateAgentRequestOptions { + const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; + const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { provider, model } = parseProviderModel(providerModel); + return { + ...requestOptions, + config: { + ...runtimeConfig, + provider, + model, + cwd: placement?.cwd ?? cwd, + ...(title !== undefined ? { title } : {}), + ...(providerOptions !== undefined ? { providerOptions } : {}), + }, + ...(placement ? { workspaceId: placement.workspaceId } : {}), + ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(prompt !== undefined ? { initialPrompt: prompt } : {}), + }; +} + export function createPaseoApi( daemonClient: DaemonClient, scopeOptions?: { signal?: AbortSignal }, @@ -555,24 +590,7 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; - const { provider, model } = parseProviderModel(providerModel); - const effectiveCwd = placement?.cwd ?? cwd; - const agent = await daemonClient.createAgent({ - ...requestOptions, - config: { - ...runtimeConfig, - provider, - model, - cwd: effectiveCwd, - ...(title !== undefined ? { title } : {}), - ...(providerOptions !== undefined ? { providerOptions } : {}), - }, - ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), - ...(prompt !== undefined ? { initialPrompt: prompt } : {}), - }); + const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -694,8 +712,11 @@ export function createPaseoApi( ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), - create: async ({ requestId, ...options }) => { - const result = await daemonClient.createWorkspace(options, requestId); + create: async ({ requestId, agent, ...options }) => { + const result = await daemonClient.createWorkspace( + { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + requestId, + ); if (result.error || !result.workspace) { throw new Error(result.error ?? "The daemon did not create a workspace"); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0b6c1b1882..a44311d76b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1681,7 +1681,7 @@ export type CreateAgentWorktreeTarget = z.infer; +export type AgentCreateRequest = z.infer; +export type WorkspaceInitialAgent = z.infer; + +export const WorkspaceCreationUpdateSchema = z.object({ + type: z.literal("workspace.create.update"), + payload: CreationSnapshotSchema.extend({ subscriptionId: z.string().optional() }), +}); +export const AgentCreationUpdateSchema = WorkspaceCreationUpdateSchema.extend({ + type: z.literal("agent.create.update"), +}); +export const CreationSubscribeResponseSchema = z.object({ + type: z.literal("creation.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + snapshot: CreationSnapshotSchema.nullable(), + error: z.string().nullable(), + }), +}); +export const AgentCreateResponseSchema = z.object({ + type: z.literal("agent.create.response"), + payload: z.object({ + requestId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable(), + creation: CreationSnapshotSchema.optional(), + }), +}); + export const WorkspaceCreateResponseSchema = z.object({ type: z.literal("workspace.create.response"), payload: z.object({ + agent: AgentSnapshotPayloadSchema.optional(), + creation: CreationSnapshotSchema.optional(), workspace: WorkspaceDescriptorPayloadSchema.nullable(), setupTerminalId: z.string().nullable(), setupSkippedReason: z.string().optional(), @@ -6649,6 +6742,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + AgentCreateResponseSchema, + WorkspaceCreationUpdateSchema, + AgentCreationUpdateSchema, + CreationSubscribeResponseSchema, WorkspaceClearAttentionResponseSchema, WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index bd4dcb16d8..814a8f3aec 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = ( export interface CreateAgentFromSessionInput { kind: "session"; + onAgentReady?: (agent: ManagedAgent) => Promise; agentId?: string; config: AgentSessionConfig; workspaceId: string; @@ -190,6 +191,8 @@ export async function createAgentCommand( agentId: snapshot.id, }); + if (input.kind === "session") await input.onAgentReady?.(snapshot); + let liveSnapshot = snapshot; let initialPromptStarted = false; let initialPromptError: unknown | null = null; diff --git a/packages/server/src/server/agent/requests/index.test.ts b/packages/server/src/server/agent/requests/index.test.ts deleted file mode 100644 index de3b010148..0000000000 --- a/packages/server/src/server/agent/requests/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { AgentRequests } from "./index.js"; - -const directories: string[] = []; -afterEach(async () => { - await Promise.all( - directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); -async function fixture() { - const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); - directories.push(directory); - return { directory, requests: new AgentRequests(directory) }; -} - -test("concurrent and reconstructed creates return the same durable agent", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - let creations = 0; - const input = { - key: "create-1", - request: { provider: "test", cwd: "/project" }, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - creations++; - agents.add(id); - }, - }; - const ids = await Promise.all([requests.create(input), requests.create(input)]); - expect(ids[0]).toBe(ids[1]); - expect(await new AgentRequests(directory).create(input)).toBe(ids[0]); - expect(creations).toBe(1); -}); - -test("recovers creation when the agent was persisted before acknowledgement failed", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - const input = { - key: "lost-create", - request: {}, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - agents.add(id); - throw new Error("acknowledgement lost"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("acknowledgement lost"); - expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]); - expect(agents.size).toBe(1); -}); - -test("reusing a create key with different configuration is a conflict", async () => { - const { requests } = await fixture(); - const input = { - key: "key", - request: { model: "a" }, - findAgent: async () => true, - create: async () => {}, - }; - await requests.create(input); - await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow( - "agent_request_key_conflict", - ); -}); - -test("message retries survive reconstruction without submitting twice", async () => { - const { requests, directory } = await fixture(); - let deliveries = 0; - const input = { - agentId: "agent", - messageId: "arrival", - request: { text: "hello" }, - send: async () => { - deliveries++; - }, - }; - await Promise.all([requests.send(input), requests.send(input)]); - await new AgentRequests(directory).send(input); - expect(deliveries).toBe(1); - await requests.send({ ...input, agentId: "another" }); - expect(deliveries).toBe(2); -}); - -test("ambiguous provider delivery is never blindly replayed after restart", async () => { - const { requests, directory } = await fixture(); - let deliveries = 0; - const input = { - agentId: "agent", - messageId: "arrival", - request: {}, - send: async () => { - deliveries++; - throw new Error("connection lost"); - }, - }; - await expect(requests.send(input)).rejects.toThrow("connection lost"); - await expect(new AgentRequests(directory).send(input)).rejects.toThrow( - "agent_request_outcome_unknown", - ); - expect(deliveries).toBe(1); -}); - -test("a creation failure with no stored agent can be retried", async () => { - const { requests } = await fixture(); - let available = false; - const input = { - key: "unavailable", - request: {}, - findAgent: async () => false, - create: async () => { - if (!available) throw new Error("provider unavailable"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("provider unavailable"); - available = true; - await expect(requests.create(input)).resolves.toEqual(expect.any(String)); -}); - -test("failed local message preparation does not leave an ambiguous receipt", async () => { - const { requests, directory } = await fixture(); - let available = false; - let sends = 0; - const input = { - agentId: "agent", - messageId: "message", - request: {}, - prepare: async () => { - if (!available) throw new Error("load failed"); - }, - send: async () => { - sends++; - }, - }; - await expect(requests.send(input)).rejects.toThrow("load failed"); - available = true; - await new AgentRequests(directory).send(input); - available = false; - await requests.send(input); - expect(sends).toBe(1); -}); diff --git a/packages/server/src/server/agent/requests/index.ts b/packages/server/src/server/agent/requests/index.ts deleted file mode 100644 index ad64331c35..0000000000 --- a/packages/server/src/server/agent/requests/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { readFile, rm } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { writeJsonFileAtomic } from "../../atomic-file.js"; - -const ReceiptSchema = z.object({ - fingerprint: z.string(), - agentId: z.string(), - state: z.enum(["pending", "completed"]), -}); -type Receipt = z.infer; - -/** One daemon-owned request journal, shared by all of its socket sessions. */ -export class AgentRequests { - private readonly pending = new Map>(); - - constructor(private readonly directory: string) {} - - create(input: { - key: string; - request: unknown; - findAgent: (agentId: string) => Promise; - create: (agentId: string) => Promise; - }): Promise { - return this.execute(["create", input.key], input.request, { - agentId: randomUUID(), - recover: input.findAgent, - run: input.create, - retrySafe: async (agentId) => !(await input.findAgent(agentId)), - }); - } - - async send(input: { - agentId: string; - messageId: string; - request: unknown; - send: () => Promise; - prepare?: () => Promise; - }): Promise { - await this.execute(["send", input.agentId, input.messageId], input.request, { - agentId: input.agentId, - // A provider call can take effect before the daemon records its outcome. - // Never repeat that call merely because a process died in this window. - recover: async () => false, - run: input.send, - prepare: input.prepare, - }); - } - - private execute( - identity: string[], - request: unknown, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const key = digest(identity); - const fingerprint = digest(request); - const previous = this.pending.get(key); - // Serialize even conflicting requests: each caller validates its own fingerprint. - const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => - this.executeOnce(key, fingerprint, operation), - ); - this.pending.set(key, result); - void result - .finally(() => { - if (this.pending.get(key) === result) this.pending.delete(key); - }) - .catch(() => undefined); - return result; - } - - private async executeOnce( - key: string, - fingerprint: string, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const file = path.join(this.directory, `${key}.json`); - const existing = await readReceipt(file); - if (existing) { - if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); - if (existing.state === "completed") return existing.agentId; - if (!(await operation.recover(existing.agentId))) { - throw new Error("agent_request_outcome_unknown"); - } - await writeJsonFileAtomic(file, { ...existing, state: "completed" }); - return existing.agentId; - } - await operation.prepare?.(); - const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" }; - await writeJsonFileAtomic(file, receipt); - try { - await operation.run(receipt.agentId); - } catch (error) { - // Keyed creation has no initial prompt. Once its normal cleanup finished, - // absence of an agent confirms that retrying cannot duplicate one. - if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true }); - throw error; - } - await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); - return receipt.agentId; - } -} - -async function readReceipt(file: string): Promise { - try { - return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; - throw error; - } -} - -function digest(value: unknown): string { - return createHash("sha256") - .update( - JSON.stringify(value, (_key, candidate: unknown) => { - if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { - return Object.fromEntries( - Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), - ); - } - return candidate; - }), - ) - .digest("hex"); -} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 6a6a1e78ec..89d0d3f030 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -62,6 +62,8 @@ const INBOUND_PERMISSION = { client_heartbeat: "workspace.read", close_items_request: "workspace.manage", create_agent_request: ["workspace.write", "hub.execute"], + "agent.create.request": ["workspace.write", "hub.execute"], + "creation.subscribe.request": "workspace.read", create_paseo_worktree_request: "workspace.manage", create_terminal_request: "workspace.write", "daemon.config.reload.request": "daemon.manage", @@ -209,6 +211,10 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "agent.create.response": ["workspace.write", "hub.execute"], + "agent.create.update": ["workspace.write", "hub.execute"], + "workspace.create.update": "workspace.manage", + "creation.subscribe.response": "workspace.read", activity_log: "workspace.read", "agent.config.apply.response": ["workspace.write", "hub.execute"], "agent.detach.response": "workspace.write", diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts new file mode 100644 index 0000000000..f0d21be18f --- /dev/null +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import type { z } from "zod"; +import { WebSocket } from "ws"; +import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import pino from "pino"; +import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => { + const provider = deferred(); + const ready = deferred(); + const disconnected = deferred(); + const cleaned = deferred(); + const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); + let agents = 0; + let prompts = 0; + const daemon = await createTestPaseoDaemon({ + logger: pino( + { level: "trace" }, + { + write(line) { + const { msg } = JSON.parse(line); + if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve(); + if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve(); + }, + }, + ), + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + agents++; + await provider.promise; + }, + onStartTurn: () => { + prompts++; + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-subscriber", + }); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-independent-observer", + }); + const observerMessages: SessionOutboundMessage[] = []; + const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message)); + const workspaceId = "wks_0123456789abcdef"; + const agentId = randomUUID(); + const request = { + idempotencyKey: "workspace-one", + workspaceId, + source: { kind: "directory" as const, path: directory }, + agent: { + agentId, + provider: "codex", + cwd: directory, + initialPrompt: "Create once", + clientMessageId: "initial-one", + }, + }; + try { + await client.connect(); + await observer.connect(); + const first = client.createWorkspace({ + ...request, + onEvent: (snapshot) => { + if (snapshot.phase === "workspace_ready") ready.resolve(snapshot); + }, + }); + void first.catch(() => undefined); + const snapshot = await ready.promise; + expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" }); + expect((await observer.fetchAgents()).entries).toHaveLength(0); + expect( + observerMessages.filter((message) => message.type === "workspace.create.update"), + ).toHaveLength(0); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await client.close(); + await disconnected.promise; + await vi.advanceTimersByTimeAsync(90_000); + await cleaned.promise; + vi.useRealTimers(); + await expect(first).rejects.toThrow("closed"); + // A disconnected form cannot prevent the provider or initial prompt from starting. + provider.resolve(); + await expect.poll(() => prompts).toBe(1); + const replay = await observer.createWorkspace(request); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + expect(replay.agent?.id).toBe(agentId); + expect(replay.agent?.title).toBe("Create once"); + expect(agents).toBe(1); + expect(prompts).toBe(1); + const conflict = await observer.createWorkspace({ + ...request, + idempotencyKey: "another-intent", + }); + expect(conflict.error).toBe("workspace_id_conflict"); + expect(agents).toBe(1); + } finally { + vi.useRealTimers(); + provider.resolve(); + unsubscribe(); + await client.close(); + await observer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); + +async function connectCreationPeer(port: number) { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const frames: SessionOutboundMessage[] = []; + socket.on("message", (data) => { + const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString())); + if (frame.type === "session") frames.push(frame.message); + }); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId: randomUUID(), + protocolVersion: 1, + appVersion: "0.8.0", + }), + ); + await expect + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .toBe(true); + return { + close: () => socket.close(), + request: async ( + message: z.input & { requestId: string }, + ) => { + socket.send(JSON.stringify({ type: "session", message })); + const response = () => + frames.find( + (m) => + "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, + ); + await expect.poll(response).toBeDefined(); + return response()!; + }, + }; +} + +test.each([false, true])( + "workspace identity does not depend on subscribing (first subscribe=%s)", + async (subscribe) => { + const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); + const daemon = await createTestPaseoDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + type: "workspace.create.request" as const, + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "same-workspace", + }; + const first = await peer.request({ ...request, requestId: "first", subscribe }); + const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe }); + if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response") + throw new Error("Expected workspace responses"); + expect(first.payload.error).toBeNull(); + expect(replay.payload.error).toBeNull(); + expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "legacy and modern agent RPCs share one creation identity (first %s)", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); + let creations = 0; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + creations++; + }, + }), + }); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + config: { provider: "codex", cwd: directory }, + idempotencyKey: "same-agent", + }; + const first = await peer.request({ ...request, type, requestId: "first" }); + const replay = await peer.request({ + ...request, + type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request", + requestId: "replay", + }); + const createdAgent = (message: SessionOutboundMessage) => { + if (message.type === "agent.create.response") { + expect(message.payload.error).toBeNull(); + return message.payload.agent; + } + if (message.type === "status" && message.payload.status === "agent_created") + return message.payload.agent; + throw new Error(`Unexpected creation result ${JSON.stringify(message)}`); + }; + expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id); + const agentId = createdAgent(first)!.id; + await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation"); + await daemon.daemon.agentManager.flush(); + const legacyReplay = await peer.request({ + ...request, + type: "create_agent_request", + requestId: "legacy-after-update", + }); + expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation"); + expect(creations).toBe(1); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test("legacy keyed creation preserves checkout error codes", async () => { + const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-")); + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: directory, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const peer = await connectCreationPeer(daemon.port); + try { + const result = await peer.request({ + type: "create_agent_request", + requestId: "missing-branch", + idempotencyKey: "missing-branch", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "checkout-branch", branch: "does-not-exist" }, + }); + expect(result).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", errorCode: "unknown_branch" }, + }); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts new file mode 100644 index 0000000000..2f2c024078 --- /dev/null +++ b/packages/server/src/server/creation/index.test.ts @@ -0,0 +1,324 @@ +import pino from "pino"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationService, type CreationInput } from "./index.js"; + +const silentLogger = pino({ level: "silent" }); +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-")); + directories.push(directory); + const service = new CreationService(directory, silentLogger); + const provider = deferred(); + const ready = deferred(); + const calls: string[] = []; + const input: CreationInput = { + kind: "workspace", + key: "submit-one", + request: { source: "/project", prompt: "Start once" }, + workspaceId: workspace.id, + agentId: agent.id, + hasAgent: true, + hasPrompt: true, + exists: async () => false, + provision: async () => { + calls.push("workspace"); + return { workspace }; + }, + createAgent: async (_id, _workspace, onReady) => { + await provider.promise; + calls.push("agent"); + await onReady(agent); + calls.push("prompt"); + return agent; + }, + }; + const updates: CreationSnapshot[] = []; + const observe = (snapshot: CreationSnapshot) => { + updates.push(snapshot); + if (snapshot.phase === "workspace_ready") ready.resolve(); + }; + return { directory, service, input, provider, ready, calls, updates, observe }; +} + +test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + expect(f.calls).toEqual(["workspace"]); + expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]); + expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); + const duplicate = f.service.create(f.input); + f.provider.resolve(); + expect(await first).toMatchObject({ phase: "completed", workspace, agent }); + expect(await duplicate).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("a failed agent startup retries only that stage with the reserved IDs", async () => { + const f = await fixture(); + let attempts = 0; + f.input.createAgent = async (id, readyWorkspace, onReady) => { + attempts++; + expect(id).toBe(agent.id); + expect(readyWorkspace).toEqual(workspace); + if (attempts === 1) throw new Error("provider unavailable"); + await onReady(agent); + return agent; + }; + expect(await f.service.create(f.input)).toMatchObject({ + phase: "failed", + failedStage: "agent", + outcomeUnknown: false, + workspace, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({ + phase: "completed", + agent, + }); + expect(f.calls).toEqual(["workspace"]); + expect(attempts).toBe(2); +}); + +test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => { + const f = await fixture(); + let prompts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + await onReady(agent); + prompts++; + throw new Error("connection lost after provider accepted prompt"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ + phase: "failed", + failedStage: "prompt", + outcomeUnknown: true, + workspace, + agent, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(prompts).toBe(1); +}); + +test("conflicting intents and resource IDs cannot start more work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow( + "workspace_request_key_conflict", + ); + await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow( + "workspace_id_conflict", + ); + f.provider.resolve(); + await first; + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("late subscribers recover readiness and removing an observer cannot stop creation", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + const updates: CreationSnapshot[] = []; + const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) => + updates.push(snapshot), + ); + expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace }); + subscription.unsubscribe(); + f.provider.resolve(); + await first; + expect(updates).toEqual([]); + const completed = await new CreationService(f.directory, silentLogger).subscribe( + "workspace", + f.input.key, + () => {}, + ); + expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace }); + completed.unsubscribe(); +}); + +test("omitted resource IDs are generated once and preserved on replay", async () => { + const f = await fixture(); + f.input.workspaceId = undefined; + f.input.agentId = undefined; + f.input.provision = async (id) => ({ workspace: { ...workspace, id } }); + f.input.createAgent = async (id, createdWorkspace, onReady) => { + const created = { ...agent, id, workspaceId: createdWorkspace!.id }; + await onReady(created); + return created; + }; + const result = await f.service.create(f.input); + expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); + expect(result.agentId).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/, + ); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); +}); + +test("restart after a committed agent without a prompt completes without recreating the agent", async () => { + const f = await fixture(); + const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-")); + directories.push(recoveredDirectory); + f.input.hasPrompt = false; + let starts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + starts++; + await onReady(agent); + // Capture the durable state at the exact milestone a process could stop after. + await cp(f.directory, recoveredDirectory, { recursive: true }); + return agent; + }; + await f.service.create(f.input); + const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); + expect(recovered).toMatchObject({ phase: "completed", workspace, agent }); + expect(starts).toBe(1); +}); + +test("partial provisioning without a registered workspace is not repeated", async () => { + const f = await fixture(); + let provisions = 0; + f.input.provision = async () => { + provisions++; + await writeFile(join(f.directory, "partial-checkout"), "disk was changed"); + throw new Error("Git failed after creating the checkout"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(provisions).toBe(1); +}); + +test.each(["pending", "completed"])( + "imports a %s legacy agent receipt without creating another agent", + async (state) => { + const f = await fixture(); + const legacyDirectory = join(f.directory, "legacy"); + await mkdir(legacyDirectory); + const request = { config: { cwd: "/project", provider: "codex" } }; + const hash = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + await writeFile( + join(legacyDirectory, `${hash(["create", "old-key"])}.json`), + JSON.stringify({ + fingerprint: hash({ ...request, type: "create_agent_request" }), + state, + agentId: agent.id, + }), + ); + const input: CreationInput = { + kind: "agent", + key: "old-key", + request, + hasAgent: true, + hasPrompt: false, + exists: async () => true, + readAgent: async () => agent, + createAgent: async () => { + throw new Error("Must not create again"); + }, + }; + const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory); + await expect( + service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }), + ).rejects.toThrow("agent_request_key_conflict"); + expect(await service.create(input)).toMatchObject({ phase: "completed", agent }); + expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({ + phase: "completed", + agent, + }); + }, +); + +test("observer failures are logged without failing creation", async () => { + const f = await fixture(); + const logs: unknown[] = []; + const logger = pino( + { level: "warn" }, + { + write: (line) => { + logs.push(JSON.parse(line)); + }, + }, + ); + const service = new CreationService(f.directory, logger); + f.provider.resolve(); + const result = await service.create(f.input, () => { + throw new Error("delivery failed"); + }); + expect(result.phase).toBe("completed"); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(logs).toContainEqual( + expect.objectContaining({ + level: 40, + kind: "workspace", + idempotencyKey: f.input.key, + phase: "workspace_ready", + err: expect.objectContaining({ message: "delivery failed" }), + }), + ); +}); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts new file mode 100644 index 0000000000..cf8b2adc2a --- /dev/null +++ b/packages/server/src/server/creation/index.ts @@ -0,0 +1,351 @@ +import type { Logger } from "pino"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + CreationSnapshotSchema, + type AgentSnapshotPayload, + type CreationSnapshot, + type WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { writeJsonFileAtomic } from "../atomic-file.js"; +import { generateWorkspaceId } from "../workspace-registry-model.js"; + +type Observer = (snapshot: CreationSnapshot) => void; +interface CreationRequest { + key: string; + request: { [key: string]: unknown }; + workspaceId?: string; + agentId?: string; + hasAgent: boolean; + hasPrompt: boolean; + exists: (kind: "workspace" | "agent", id: string) => Promise; + provision?: ( + workspaceId: string, + ) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>; + createAgent?: ( + agentId: string, + workspace: WorkspaceDescriptorPayload | undefined, + onReady: (agent: AgentSnapshotPayload) => Promise, + ) => Promise; +} +export type CreationInput = CreationRequest & + ( + | { kind: "workspace" } + | { kind: "agent"; readAgent: (id: string) => Promise } + ); +const RecordSchema = z.object({ + fingerprint: z.string(), + snapshot: CreationSnapshotSchema, + inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(), +}); +type Record = z.infer; + +/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */ +export class CreationService { + private admission: Promise = Promise.resolve(); + private readonly active = new Map>(); + private readonly observers = new Map>(); + constructor( + private readonly directory: string, + private readonly logger: Logger, + private readonly validateCompleted: ( + snapshot: CreationSnapshot, + ) => Promise = async () => {}, + private readonly legacyDirectory?: string, + ) {} + + async create(input: CreationInput, observer?: Observer): Promise { + const identity = identityFor(input.kind, input.key); + const admitted = this.admission.then(async () => { + await mkdir(this.directory, { recursive: true }); + const fingerprint = digest(input.request); + let record = await this.read(identity); + if (!record) record = await this.readLegacyAgent(input, fingerprint); + if (record && record.fingerprint !== fingerprint) + throw new Error(`${input.kind}_request_key_conflict`); + const running = this.active.get(identity); + if (running && record) return { completion: running, snapshot: record.snapshot }; + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) { + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (record?.inFlight) { + await this.publish(identity, record, { + phase: "failed", + error: `${input.kind}_request_outcome_unknown`, + failedStage: record.inFlight, + outcomeUnknown: true, + }); + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (!record) { + record = initialRecord(input, fingerprint); + // Persist identity before claiming resources. A failed admission can only retry these IDs. + await this.write(identity, record); + } + await this.claimResources(identity, record.snapshot, input); + if (record.snapshot.phase === "failed") { + await this.publish(identity, record, { + phase: record.snapshot.workspace ? "workspace_ready" : "accepted", + error: null, + errorCode: undefined, + failedStage: undefined, + outcomeUnknown: undefined, + }); + } + const snapshot = record.snapshot; + // Defer the runner until admission has installed the initiating observer. + const completion = Promise.resolve().then(() => this.run(identity, record, input)); + this.active.set(identity, completion); + void completion.finally(() => this.active.delete(identity)).catch(() => undefined); + return { completion, snapshot }; + }); + this.admission = admitted.then( + () => undefined, + () => undefined, + ); + // Install before admission starts, so even a fast local provision cannot outrun observation. + const unsubscribe = observer ? this.observe(identity, observer) : () => {}; + try { + const { completion, snapshot } = await admitted; + if (observer) this.notify(observer, snapshot); + return await completion; + } finally { + unsubscribe(); + } + } + + async subscribe( + kind: CreationSnapshot["kind"], + key: string, + observer: Observer, + ): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> { + const identity = identityFor(kind, key); + const unsubscribe = this.observe(identity, observer); + try { + await this.admission; + const record = await this.read(identity); + if (record?.inFlight && !this.active.has(identity)) { + await this.publish(identity, record, { + phase: "failed", + failedStage: record.inFlight, + outcomeUnknown: true, + error: `${kind}_request_outcome_unknown`, + }); + } + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + return { snapshot: record?.snapshot ?? null, unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } + } + + private async run( + identity: string, + record: Record, + input: CreationInput, + ): Promise { + try { + if (input.provision && !record.snapshot.workspace) { + record.inFlight = "workspace"; + await this.write(identity, record); + const { workspace, setupSkippedReason } = await input.provision( + record.snapshot.workspaceId!, + ); + record.inFlight = null; + await this.publish(identity, record, { + phase: "workspace_ready", + workspace, + workspaceId: workspace.id, + setupSkippedReason, + }); + } + if (input.hasAgent && input.createAgent && !record.snapshot.agent) { + record.inFlight = "agent"; + await this.write(identity, record); + const agent = await input.createAgent( + record.snapshot.agentId!, + record.snapshot.workspace, + async (readyAgent) => { + record.inFlight = input.hasPrompt ? "prompt" : null; + await this.publish(identity, record, { + phase: "agent_ready", + agent: readyAgent, + workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId, + }); + }, + ); + record.inFlight = null; + if (input.hasPrompt) + await this.publish(identity, record, { phase: "prompt_started", agent }); + else record.snapshot = { ...record.snapshot, agent }; + } + record.inFlight = null; + await this.publish(identity, record, { phase: "completed" }); + } catch (error) { + const stage = record.inFlight ?? "agent"; + const resourceId = + stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId; + const code = + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; + const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required"; + const unknown = + stage === "prompt" || + (stage === "workspace" && !rejectedBeforeProvision) || + (resourceId !== null && (await input.exists(stage, resourceId))); + // A failed provider startup with no registered resource can retry under the same ID. + // A partially provisioned resource or an attempted prompt needs reconciliation. + if (!unknown) record.inFlight = null; + await this.publish(identity, record, { + phase: "failed", + error: error instanceof Error ? error.message : String(error), + errorCode: code, + failedStage: stage, + outcomeUnknown: unknown, + }); + } + return record.snapshot; + } + + // COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire. + // Import the previous receipt format; it is never a second execution path. + private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise { + if (input.kind !== "agent" || !this.legacyDirectory) return null; + const text = await readOptional( + join(this.legacyDirectory, `${digest(["create", input.key])}.json`), + ); + if (text === null) return null; + const receipt = z + .object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), + }) + .parse(JSON.parse(text)); + if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request })) + throw new Error("agent_request_key_conflict"); + const agent = await input.readAgent(receipt.agentId); + if (!agent && receipt.state === "completed") + throw new Error("Previously created agent no longer exists"); + const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint); + record.snapshot = { + ...record.snapshot, + phase: agent ? "completed" : "failed", + agent: agent ?? undefined, + workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId, + error: agent ? null : "agent_request_outcome_unknown", + ...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }), + }; + await this.write(identityFor(input.kind, input.key), record); + return record; + } + + private async claimResources( + identity: string, + snapshot: CreationSnapshot, + input: CreationInput, + ): Promise { + if (input.kind === "workspace" && snapshot.workspaceId) + await this.claim(identity, "workspace", snapshot.workspaceId, input.exists); + if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists); + } + + private async claim( + identity: string, + kind: "workspace" | "agent", + id: string, + exists: CreationInput["exists"], + ): Promise { + const file = join(this.directory, `${digest([kind, id])}.claim`); + const owner = await readOptional(file); + if (owner !== null) { + if (owner !== identity) throw new Error(`${kind}_id_conflict`); + return; + } + if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`); + await writeFile(file, identity, { flag: "wx", mode: 0o600 }); + } + private observe(identity: string, observer: Observer): () => void { + const observers = this.observers.get(identity) ?? new Set(); + observers.add(observer); + this.observers.set(identity, observers); + return () => { + observers.delete(observer); + if (!observers.size) this.observers.delete(identity); + }; + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch (err) { + this.logger.warn( + { + err, + kind: snapshot.kind, + idempotencyKey: snapshot.idempotencyKey, + phase: snapshot.phase, + }, + "Creation observer failed", + ); + } + } + private async publish( + identity: string, + record: Record, + update: Partial, + ): Promise { + record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 }; + await this.write(identity, record); + for (const observer of this.observers.get(identity) ?? []) + this.notify(observer, record.snapshot); + } + private write(identity: string, record: Record): Promise { + return writeJsonFileAtomic(join(this.directory, `${identity}.json`), record); + } + private async read(identity: string): Promise { + const text = await readOptional(join(this.directory, `${identity}.json`)); + return text === null ? null : RecordSchema.parse(JSON.parse(text)); + } +} +function initialRecord(input: CreationInput, fingerprint: string): Record { + return { + fingerprint, + inFlight: null, + snapshot: { + kind: input.kind, + idempotencyKey: input.key, + revision: 0, + phase: "accepted", + error: null, + workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null), + agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null, + }, + }; +} +function identityFor(kind: string, key: string): string { + return digest([kind, key]); +} +function digest(input: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(input, (_key, value: unknown) => { + if (value && typeof value === "object" && !Array.isArray(value)) + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + return value; + }), + ) + .digest("hex"); +} +async function readOptional(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index b37e12657d..5133f2f6c9 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -9,66 +9,75 @@ import { createObserverPaths } from "./paths.js"; // Native watchers may coalesce file removals into change notifications. Keep the // filesystem real while controlling which notifications reach reconciliation. -test("shallow parent scans retain nested change scopes", async () => { - const root = await mkdtemp(join(tmpdir(), "native-scopes-")); - const paths = createObserverPaths(process.platform); - const removed = [ - join(root, "root.txt"), - join(root, "child", "child.txt"), - join(root, "child", "deep", "deep.txt"), - ]; - await mkdir(join(root, "child", "deep"), { recursive: true }); - await Promise.all(removed.map((path) => writeFile(path, "before"))); - const events: FileChange[] = []; - const notifications = new EventEmitter(); - let active = true; - const observer = createFileObserver(); - const backend = createNativeRecursiveBackend( - { - root, - metrics: observer.getDiagnostics(), - isActive: () => active, - isIgnored: () => false, - isPathInside: paths.isInside, - queueEvent: (type, path) => events.push({ type, path }), - fail: (error) => { - throw error; - }, - }, - paths, - (_root, listener) => { - notifications.on("change", listener); - return { - close: () => { - notifications.removeAllListeners(); +test.each(["existing", "new"])( + "shallow scans recover coalesced deletes of %s files in nested scopes", + async (population) => { + const root = await mkdtemp(join(tmpdir(), "native-scopes-")); + const paths = createObserverPaths(process.platform); + const removed = [ + join(root, "root.txt"), + join(root, "child", "child.txt"), + join(root, "child", "deep", "deep.txt"), + ]; + await mkdir(join(root, "child", "deep"), { recursive: true }); + if (population === "existing") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + } + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; }, - on: (event, onError) => notifications.on(event, onError), - }; - }, - ); - try { - await backend.start(); - await Promise.all(removed.map((path) => rm(path))); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); - for (const path of removed) notifications.emit("change", "change", path); - await vi.advanceTimersByTimeAsync(8_000); - vi.useRealTimers(); - await expect - .poll(() => - events - .filter((event) => event.type === "delete") - .map((event) => event.path) - .sort(), - ) - .toEqual([...removed].sort()); - } finally { - vi.useRealTimers(); - active = false; - await backend.close(); - await observer.close(); - await rm(root, { recursive: true, force: true }); - } -}); + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + if (population === "new") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + for (const path of removed) notifications.emit("change", "change", path); + } + await Promise.all(removed.map((path) => rm(path))); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + for (const path of removed) notifications.emit("change", "change", path); + await vi.advanceTimersByTimeAsync(8_000); + vi.useRealTimers(); + await expect + .poll(() => + events + .filter((event) => event.type === "delete") + .map((event) => event.path) + .sort(), + ) + .toEqual([...removed].sort()); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); test("a file announced only as changed remains visible to coalesced deletion scans", async () => { const root = await mkdtemp(join(tmpdir(), "native-change-only-")); diff --git a/packages/server/src/server/message-receipts/index.test.ts b/packages/server/src/server/message-receipts/index.test.ts new file mode 100644 index 0000000000..10c1615a6a --- /dev/null +++ b/packages/server/src/server/message-receipts/index.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { MessageReceipts } from "./index.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +async function fixture() { + const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); + directories.push(directory); + return { directory, requests: new MessageReceipts(directory) }; +} + +test("message retries survive reconstruction without submitting twice", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: { text: "hello" }, + send: async () => { + deliveries++; + }, + }; + await Promise.all([requests.send(input), requests.send(input)]); + await new MessageReceipts(directory).send(input); + expect(deliveries).toBe(1); + await requests.send({ ...input, agentId: "another" }); + expect(deliveries).toBe(2); +}); + +test("ambiguous provider delivery is never blindly replayed after restart", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: {}, + send: async () => { + deliveries++; + throw new Error("connection lost"); + }, + }; + await expect(requests.send(input)).rejects.toThrow("connection lost"); + await expect(new MessageReceipts(directory).send(input)).rejects.toThrow( + "agent_request_outcome_unknown", + ); + expect(deliveries).toBe(1); +}); + +test("failed local message preparation does not leave an ambiguous receipt", async () => { + const { requests, directory } = await fixture(); + let available = false; + let sends = 0; + const input = { + agentId: "agent", + messageId: "message", + request: {}, + prepare: async () => { + if (!available) throw new Error("load failed"); + }, + send: async () => { + sends++; + }, + }; + await expect(requests.send(input)).rejects.toThrow("load failed"); + available = true; + await new MessageReceipts(directory).send(input); + available = false; + await requests.send(input); + expect(sends).toBe(1); +}); diff --git a/packages/server/src/server/message-receipts/index.ts b/packages/server/src/server/message-receipts/index.ts new file mode 100644 index 0000000000..6103160b12 --- /dev/null +++ b/packages/server/src/server/message-receipts/index.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../atomic-file.js"; + +const ReceiptSchema = z.object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), +}); +interface SendMessageInput { + agentId: string; + messageId: string; + request: unknown; + send: () => Promise; + prepare?: () => Promise; +} + +/** Owns message delivery receipts; creation is owned by CreationService. */ +export class MessageReceipts { + private readonly pending = new Map>(); + constructor(private readonly directory: string) {} + + 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); + const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.sendOnce(key, input), + ); + this.pending.set(key, result); + void result + .finally(() => { + if (this.pending.get(key) === result) this.pending.delete(key); + }) + .catch(() => undefined); + return result; + } + + 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; + // A provider may have accepted the message before its receipt was committed. + throw new Error("agent_request_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" }); + } +} + +async function readReceipt(file: string): Promise | null> { + try { + return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function digest(value: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(value, (_key, candidate: unknown) => { + if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { + return Object.fromEntries( + Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), + ); + } + return candidate; + }), + ) + .digest("hex"); +} diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..dc93e30298 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + workspaceId?: string; projectId?: string; title?: string; } @@ -93,6 +94,7 @@ async function createPaseoWorktreeWithPriority( const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, + workspaceId: input.workspaceId, repoRoot: createdWorktree.repoRoot, cwd: workspaceCwd, worktreeRoot: createdWorktree.worktree.worktreePath, diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 48af2cd80c..a6a27ba5bc 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("a plugin transforms workspace creation and observes its committed lifecycle", async () => { +test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -19,6 +19,7 @@ test("a plugin transforms workspace creation and observes its committed lifecycl ` export default function contribute(server) { server.before("workspace.create", ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); return { ...request, title: "Created through a hook" }; }); server.on("workspace.created", (event) => { @@ -36,10 +37,17 @@ export default function contribute(server) { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", source: { kind: "directory", path: directory }, }); expect(created.workspace?.name).toBe("Created through a hook"); const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); await client.archiveWorkspace(workspaceId); await expect .poll(async () => { @@ -53,6 +61,7 @@ export default function contribute(server) { }); }) .toMatchObject([ + { hook: "workspace.before" }, { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, ]); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index a32f5265c9..7a6384f9a6 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -364,7 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 21aca8bf90..a5daf44b52 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,11 @@ import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { AgentRequests } from "./agent/requests/index.js"; +import { relative } from "node:path"; +import { isAbsolute } from "node:path"; +import { CreationService } from "./creation/index.js"; +import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; +import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; @@ -195,7 +199,6 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, - WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -452,7 +455,8 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; - agentRequests: Pick; + messageReceipts: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -621,6 +625,16 @@ function resolveWorkspaceLabelService( return service ?? null; } +function hasInitialCreationPrompt( + request?: Pick, +): boolean { + return Boolean( + request && + buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments) + .length, + ); +} + function workspaceLabelErrorCode(error: unknown): string { if ( error instanceof WorkspaceLabelError || @@ -765,8 +779,9 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; - private readonly agentRequests: Pick; + private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -840,7 +855,8 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; - this.agentRequests = options.agentRequests; + this.messageReceipts = options.messageReceipts; + this.creationService = options.creationService; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; @@ -2255,6 +2271,7 @@ export class Session { this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentTimelineMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? + this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -2631,6 +2648,22 @@ export class Session { return undefined; } + private dispatchCreationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + switch (msg.type) { + case "agent.create.request": + return this.handleAgentCreation(msg, source); + case "creation.subscribe.request": + return this.handleCreationSubscription(msg, source); + case "workspace.create.request": + return this.handleWorkspaceCreation(msg, source); + default: + return undefined; + } + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -2828,8 +2861,6 @@ export class Session { return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": return this.handleProjectRemoveRequest(msg); - case "workspace.create.request": - return this.handleWorkspaceCreateRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -3814,6 +3845,265 @@ export class Session { /** * Handle create agent request */ + private creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage { + return { + type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update", + payload: snapshot, + }; + } + + private async handleCreationSubscription( + request: Extract, + source?: object, + ): Promise { + let unsubscribe = () => {}; + const owner = + request.subscribe === false + ? undefined + : this.delivery.begin( + "creation", + undefined, + () => unsubscribe(), + JSON.stringify([request.kind, request.idempotencyKey]), + ); + try { + const observation = await this.creationService.subscribe( + request.kind, + request.idempotencyKey, + (snapshot) => owner?.emit(this.creationUpdate(snapshot)), + ); + unsubscribe = observation.unsubscribe; + if (!owner || owner.signal.aborted) unsubscribe(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner?.responseId, + snapshot: observation.snapshot, + error: null, + }, + }, + source, + ); + } catch (error) { + await owner?.release(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + snapshot: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + + private creationResourceExists = async ( + kind: "workspace" | "agent", + id: string, + ): Promise => + kind === "workspace" + ? (await this.workspaceRegistry.get(id)) !== null + : this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null; + + private createRequestedAgent( + request: AgentCreateRequest, + observer?: (snapshot: CreationSnapshot) => void, + ): Promise { + const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; + return this.creationService.create( + { + kind: "agent", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: request.agentId, + hasAgent: true, + hasPrompt: hasInitialCreationPrompt(request), + exists: this.creationResourceExists, + readAgent: async (id) => { + const record = await this.agentStorage.get(id); + return record ? this.buildStoredAgentPayload(record) : null; + }, + createAgent: async (id, _workspace, onReady) => { + try { + return await this.createSessionAgent( + { ...request, type: "create_agent_request" }, + id, + onReady, + ); + } catch (error) { + throw new WorktreeRequestError(toWorktreeWireError(error)); + } + }, + }, + observer, + ); + } + + private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise { + const { requestId, subscribe } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + const creation = await this.createRequestedAgent( + request, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "agent.create.response", + payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.create.response", + payload: { + requestId, + agent: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + + private async handleWorkspaceCreation( + request: Extract, + source?: object, + ): Promise { + const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + if ( + request.agent && + !this.authorization.allowsPermission("workspace.write") && + !this.authorization.allowsPermission("hub.execute") + ) { + 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, + }, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: creation.workspace ?? null, + agent: creation.agent, + creation, + setupSkippedReason: creation.setupSkippedReason, + error: creation.error, + errorCode: creation.errorCode, + setupTerminalId: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: null, + error: error instanceof Error ? error.message : String(error), + errorCode: + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined, + setupTerminalId: null, + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { try { let agent: AgentSnapshotPayload; @@ -3821,18 +4111,13 @@ export class Session { if (msg.initialPrompt !== undefined) { throw new Error("Idempotent creation requires sending the initial prompt separately"); } - const { requestId: _requestId, idempotencyKey, ...request } = msg; - const id = await this.agentRequests.create({ - key: idempotencyKey, - request, - findAgent: async (agentId) => - this.agentManager.getAgent(agentId) != null || - (await this.agentStorage.get(agentId)) !== null, - create: async (agentId) => { - await this.createSessionAgent(msg, agentId); - }, - }); - const record = await this.agentStorage.get(id); + const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" }); + if (creation.error || !creation.agent) + throw new SessionRequestError( + creation.errorCode ?? "unknown", + creation.error ?? "Agent creation failed", + ); + const record = await this.agentStorage.get(creation.agent.id); if (!record) throw new Error("Previously created agent no longer exists"); agent = this.buildStoredAgentPayload(record); } else { @@ -3848,7 +4133,7 @@ export class Session { }, }); } catch (error) { - const wireError = toWorktreeWireError(error); + const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); this.emit({ type: "status", @@ -3874,6 +4159,7 @@ export class Session { private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, ): Promise { const { config, @@ -3943,6 +4229,10 @@ export class Session { }, { kind: "session", + onAgentReady: async (agent) => { + createdAgentId = agent.id; + await onAgentReady?.(await this.buildAgentPayload(agent)); + }, agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, @@ -6338,61 +6628,35 @@ export class Session { } } - private async handleWorkspaceCreateRequest( + private async createRequestedWorkspace( request: Extract, - ): Promise { - try { - if (this.pluginRuntime) { - const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); - request = { ...transformed, type, requestId }; - } - if (request.source.kind === "directory") { - await this.handleWorkspaceCreateLocal(request); - return; - } - await this.handleWorkspaceCreateWorktree(request); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create workspace"; - this.sessionLogger.error( - { err: error, sourceKind: request.source.kind, requestId: request.requestId }, - "Failed to create workspace", - ); - const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: message, - errorCode, - }, - }); + workspaceId?: string, + ): Promise { + let creationRequest = request; + // Hooks belong to the operation: retries fingerprint the caller's input + // and must not rerun hooks or compare their potentially changing output. + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + creationRequest = { ...transformed, type, requestId }; } + return creationRequest.source.kind === "directory" + ? this.handleWorkspaceCreateLocal(creationRequest, workspaceId) + : this.handleWorkspaceCreateWorktree(creationRequest, workspaceId); } private async handleWorkspaceCreateLocal( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "directory") { - return; + throw new Error("Unexpected workspace source"); } const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: `Directory not found: ${cwd}`, - errorCode: "directory_not_found", - }, - }); - return; + throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`); } const explicitTitle = request.title?.trim() || null; @@ -6401,19 +6665,10 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, @@ -6437,29 +6692,24 @@ export class Session { { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } + return descriptor; } private async handleWorkspaceCreateWorktree( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "worktree") { - return; + throw new Error("Unexpected workspace source"); } const source = request.source; if (!source.cwd && !source.projectId) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd or projectId is required for a worktree-backed workspace", - errorCode: "source_required", - }, - }); - return; + throw new SessionRequestError( + "source_required", + "cwd or projectId is required for a worktree-backed workspace", + ); } const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); @@ -6467,6 +6717,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6483,26 +6734,11 @@ export class Session { ); const descriptor = await this.describeCreatedWorktreeWorkspace(result); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - ...(result.workspace.untrustedSource - ? { - setupSkippedReason: formatWorkspaceAutomationBlockedMessage( - result.workspace.untrustedSource, - ), - } - : {}), - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, ); + return descriptor; } private async handleOpenProjectRequest( @@ -7763,6 +7999,19 @@ export class Session { } } + private async prepareAgentMessage(agentId: string, text: string): Promise { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const stored = await this.agentStorage.get(agentId); + if (stored && !stored.title && !stored.lastUserMessageAt) { + const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text }); + if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -7813,16 +8062,12 @@ export class Session { } }; if (msg.messageId) { - await this.agentRequests.send({ + await this.messageReceipts.send({ agentId, messageId: msg.messageId, request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, prepare: async () => { - await ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); + await this.prepareAgentMessage(agentId, msg.text); }, send, }); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 581a142572..836703fd30 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -185,7 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index c444d9ed24..e848e46f01 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; // Invariant tests for cwd → workspace resolution on the open_project_request path. // Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`. // Run to see which invariants the current code already satisfies (green) and which @@ -89,7 +92,8 @@ function createHarness(input: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test", permissions: OWNER_PERMISSIONS, appVersion: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e8df2788cf..2866732abe 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -642,7 +645,8 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -1003,7 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1157,7 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1295,7 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1565,7 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2048,7 +2056,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2217,7 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2377,7 +2387,8 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3649,7 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index a89e044883..ecd338ff4e 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -40,6 +40,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -61,7 +62,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -201,7 +202,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -211,7 +212,7 @@ export function createWorkspaceProvisioningService(deps: { await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, @@ -237,7 +238,7 @@ export function createWorkspaceProvisioningService(deps: { }); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: input.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "created_worktree", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 8e32c27cbe..2cfe5d3e18 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -100,7 +103,8 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, 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 323e9944c7..2a437f9bbf 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -62,6 +62,7 @@ interface FakeAgentSessionOptions { } export interface TestAgentClientOptions { + beforeCreateSession?: () => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput) => void; supportsMcpServers?: boolean; @@ -1212,6 +1213,7 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeCreateSession?.(); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a6f404e62a..1c98686042 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,5 +1,10 @@ +import pino from "pino"; import { randomUUID } from "node:crypto"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CreationService } from "../creation/index.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -281,13 +286,8 @@ export function createProviderSnapshotManagerStub(): { }; } -export function createAgentRequestsStub(): SessionOptions["agentRequests"] { +export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] { return { - async create(input) { - const agentId = randomUUID(); - await input.create(agentId); - return agentId; - }, send: (input) => input.send(), }; } @@ -304,3 +304,17 @@ export function createProviderSnapshot( }), }; } + +const creationDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + creationDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +export function createTestCreationService(): SessionOptions["creationService"] { + const directory = join(tmpdir(), `session-creation-${randomUUID()}`); + creationDirectories.push(directory); + return new CreationService(directory, pino({ level: "silent" })); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d14094a674..705a53342e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,7 @@ -import { AgentRequests } from "./agent/requests/index.js"; +import { stat } from "node:fs/promises"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { CreationService } from "./creation/index.js"; +import { MessageReceipts } from "./message-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; @@ -529,7 +532,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; - private readonly agentRequests: AgentRequests; + private readonly messageReceipts: MessageReceipts; + private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -585,6 +589,25 @@ export class VoiceAssistantWebSocketServer { private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly orchestrationSkills: SessionOptions["orchestrationSkills"]; + private async validateCompletedCreation(snapshot: CreationSnapshot): Promise { + if (snapshot.workspace && snapshot.kind === "workspace") { + const workspace = await this.workspaceRegistry.get(snapshot.workspace.id); + if (!workspace) throw new Error("Previously created workspace no longer exists"); + const directory = await stat(workspace.cwd).catch(() => null); + if (!directory?.isDirectory()) + throw Object.assign(new Error(`Directory not found: ${workspace.cwd}`), { + code: "directory_not_found", + }); + } + if ( + snapshot.agentId && + !this.agentManager.getAgent(snapshot.agentId) && + !(await this.agentStorage.get(snapshot.agentId)) + ) { + throw new Error("Previously created agent no longer exists"); + } + } + constructor( server: HTTPServer, logger: pino.Logger, @@ -649,7 +672,13 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; - this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); + this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests")); + this.creationService = new CreationService( + join(paseoHome, "creations"), + this.logger.child({ module: "creation" }), + (snapshot) => this.validateCompletedCreation(snapshot), + join(paseoHome, "agent-requests"), + ); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -1425,7 +1454,8 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, - agentRequests: this.agentRequests, + messageReceipts: this.messageReceipts, + creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1646,6 +1676,8 @@ export class VoiceAssistantWebSocketServer { features: { ownedSubscriptions: true, agentRequestReceipts: true, + workspaceRequestReceipts: true, + creationLifecycle: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index aca7046728..34abdebc3f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -208,7 +211,8 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts index bc42b77596..a5c442fcd6 100644 --- a/packages/server/src/server/workspace-create-errors.e2e.test.ts +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => { rmSync(missingDir, { recursive: true, force: true }); } }, 180000); + +test("workspace creation replay rejects a checkout removed after completion", async () => { + const daemon = await createTestPaseoDaemon(); + const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-")); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + try { + await client.connect(); + const request = { + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "removed-checkout", + }; + const created = await client.createWorkspace(request); + expect(created.error).toBeNull(); + expect(created.workspace).not.toBeNull(); + rmSync(directory, { recursive: true, force: true }); + const replayed = await client.createWorkspace(request); + expect(replayed.workspace).toBeNull(); + expect(replayed.errorCode).toBe("directory_not_found"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 180000);