From 4b8977aa67677f84ebc7d4181ab60fa6bf909930 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 20:33:23 +0200 Subject: [PATCH] fix(creation): recover draft retries without accepting partial workspaces --- .../e2e/browser/creation-idempotency.spec.ts | 344 ++++-------------- packages/app/e2e/support/creation-fixtures.ts | 43 +++ packages/app/e2e/support/helpers/creation.ts | 234 ++++++++++++ .../src/composer/draft/create-flow.test.ts | 36 -- .../app/src/composer/draft/create-flow.ts | 19 +- .../app/src/screens/new-workspace-screen.tsx | 5 +- .../app/src/stores/create-flow-store.test.ts | 27 ++ packages/app/src/stores/create-flow-store.ts | 9 +- .../src/server/request-receipts/index.test.ts | 12 +- .../src/server/request-receipts/index.ts | 4 +- packages/server/src/server/session.ts | 42 ++- .../workspace-create-errors.e2e.test.ts | 29 +- 12 files changed, 456 insertions(+), 348 deletions(-) create mode 100644 packages/app/e2e/support/creation-fixtures.ts create mode 100644 packages/app/e2e/support/helpers/creation.ts diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts index 239226c417..2976f00459 100644 --- a/packages/app/e2e/browser/creation-idempotency.spec.ts +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -1,304 +1,92 @@ -import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; -import { expect, test, type Page } from "../support/fixtures"; -import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { gotoAppShell } from "../support/helpers/app"; -import { gotoWorkspace } from "../support/helpers/launcher"; -import { fillComposerDraft } from "../support/helpers/composer"; -import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; -import { - delayBrowserAgentCreatedStatus, - openNewWorkspaceComposer, - selectWorkspaceIsolation, -} from "../support/helpers/new-workspace"; -import { seedWorkspace } from "../support/helpers/seed-client"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; - -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 })); - } - }); -} - -function observeCreationRequests(page: Page) { - const pending = new Set(); - page.on("websocket", (socket) => { - socket.on("framesent", ({ payload }) => { - const envelope = JSON.parse(payload.toString()) as { message?: SessionInboundMessage }; - const request = envelope.message; - if ( - request?.type === "workspace.create.request" || - request?.type === "create_agent_request" - ) { - pending.add(request.requestId); - } - }); - socket.on("framereceived", ({ payload }) => { - const envelope = JSON.parse(payload.toString()) as { message?: SessionOutboundMessage }; - const response = envelope.message; - if ( - response?.type === "workspace.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); - }, - }; -} - -async function retryNextAgentCreation(page: Page) { - 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 envelope = JSON.parse(frame.toString()) as { message?: SessionInboundMessage }; - const request = envelope.message; - if (!repeated && request?.type === "create_agent_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({ ...envelope, message: { ...request, requestId } })); - } - return; - } - server.send(frame); - }); - server.onMessage((frame) => { - const envelope = JSON.parse(frame.toString()) as { message?: SessionOutboundMessage }; - const response = envelope.message; - if ( - response?.type === "status" && - (response.payload.status === "agent_created" || - response.payload.status === "agent_create_failed") && - typeof response.payload.requestId === "string" && - retryIds.delete(response.payload.requestId) - ) { - results.push(response.payload); - } - 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); - }, - }; -} +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 ({ - page, + creation, }) => { - const requests = observeCreationRequests(page); - const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, project); - await selectWorkspaceIsolation(page, isolation); - - await pressSubmitBeforeTheNextRender(page, "Create"); - - await expect(page).toHaveURL(/\/workspace\//); - await requests.settled(); - const workspaces = await project.client.fetchWorkspaces(); - expect( - workspaces.entries - .filter( - (workspace) => - workspace.projectId === project.projectId && workspace.id !== project.workspaceId, - ) - .map((workspace) => workspace.id), - ).toHaveLength(1); - } finally { - await project.cleanup(); - } + await creation.openWorkspaceForm(isolation); + await creation.submitRepeatedly("Create"); + await creation.expectOneCreatedWorkspace(); }); } -test("repeated Send clicks before a render create only one agent", async ({ page }) => { - const requests = observeCreationRequests(page); - const project = await seedWorkspace({ repoPrefix: "agent-creation-idempotency-" }); - try { - await gotoWorkspace(page, project.workspaceId); - await createAgentTabFromMenu(page); - await fillComposerDraft(page, "Start exactly one agent for this prompt."); - - await pressSubmitBeforeTheNextRender(page, "Send message"); - - await expect(page.getByTestId("user-message").first()).toBeVisible(); - await requests.settled(); - const agents = await project.client.fetchAgents(); - expect( - agents.entries - .filter(({ agent }) => agent.workspaceId === project.workspaceId) - .map(({ agent }) => agent.id), - ).toHaveLength(1); - } finally { - await project.cleanup(); - } +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 ({ - page, + creation, + agentRetries, }) => { - const retries = await retryNextAgentCreation(page); - const project = await seedWorkspace({ repoPrefix: "agent-creation-retries-" }); - try { - await gotoWorkspace(page, project.workspaceId); - await createAgentTabFromMenu(page); - await fillComposerDraft(page, "Start exactly one agent for this prompt."); - await page.getByRole("button", { name: "Send message", exact: true }).click(); - - const agentIds = await retries.completedAgentIds(); - - expect(new Set(agentIds).size).toBe(1); - const agents = await project.client.fetchAgents(); - expect( - agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId), - ).toHaveLength(1); - } finally { - await project.cleanup(); - } + 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 ({ page }) => { - const project = await seedWorkspace({ repoPrefix: "creation-title-" }); - try { - await gotoWorkspace(page, project.workspaceId); - await createAgentTabFromMenu(page); - await fillComposerDraft(page, "Name this new agent from its first prompt."); - await page.getByRole("button", { name: "Send message", exact: true }).click(); - await expect(page.getByTestId("user-message").first()).toBeVisible(); - await expect - .poll(async () => { - const agents = await project.client.fetchAgents(); - return agents.entries.find(({ agent }) => agent.workspaceId === project.workspaceId)?.agent - .title; - }) - .toBe("Name this new agent from its first prompt."); - } finally { - await project.cleanup(); - } +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("retrying after a lost first-prompt acknowledgement reuses the agent and message", async ({ - page, + creation, + promptRetry, }) => { - const gate = await installDaemonWebSocketGate(page); - const project = await seedWorkspace({ repoPrefix: "creation-lost-ack-" }); - try { - await gotoWorkspace(page, project.workspaceId); - await createAgentTabFromMenu(page); - gate.holdNextServerMessage("send_agent_message_response"); - await fillComposerDraft(page, "Deliver this initial prompt once."); - await page.getByRole("button", { name: "Send message", exact: true }).click(); - await gate.waitForHeldServerMessage("send_agent_message_response"); - const firstMessage = gate.getClientRequests("send_agent_message_request").at(-1); - const agentFetches = gate.getClientRequestCount("fetch_agents_request"); - await gate.drop(); - gate.restore(); - await expect - .poll(() => gate.getClientRequestCount("fetch_agents_request")) - .toBeGreaterThan(agentFetches); - await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); - await fillComposerDraft(page, "Deliver this initial prompt once."); - await page.getByRole("button", { name: "Send message", exact: true }).click(); - await expect.poll(() => gate.getClientRequestCount("send_agent_message_request")).toBe(2); - const secondMessage = gate.getClientRequests("send_agent_message_request").at(-1); - expect(secondMessage).toMatchObject({ - agentId: firstMessage?.agentId, - messageId: firstMessage?.messageId, - }); - await expect(page.getByTestId("user-message")).toHaveCount(1); - const agents = await project.client.fetchAgents(); - expect( - agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId), - ).toHaveLength(1); - } finally { - gate.restore(); - await project.cleanup(); - } + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await promptRetry.disconnectAndReconnect(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("a remounted draft unlocks when its original submission fails", 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 creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); }); test("repeated new-workspace prompt submissions create one workspace and one agent", async ({ - page, + creation, }) => { - const requests = observeCreationRequests(page); - const project = await seedWorkspace({ repoPrefix: "creation-workspace-prompt-" }); - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, project); - await selectWorkspaceIsolation(page, "local"); - await fillComposerDraft(page, "Start one agent in one new workspace."); - await pressSubmitBeforeTheNextRender(page, "Create"); - await expect(page).toHaveURL(/\/workspace\//); - await expect(page.getByTestId("user-message").first()).toBeVisible(); - await requests.settled(); - const workspaces = (await project.client.fetchWorkspaces()).entries.filter( - (workspace) => - workspace.projectId === project.projectId && workspace.id !== project.workspaceId, - ); - expect(workspaces).toHaveLength(1); - const agents = await project.client.fetchAgents(); - expect( - agents.entries.filter(({ agent }) => agent.workspaceId === workspaces[0]!.id), - ).toHaveLength(1); - } finally { - await project.cleanup(); - } + 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 ({ - page, + creation, + delayedCreation, }) => { - const delayed = await delayBrowserAgentCreatedStatus(page); - const project = await seedWorkspace({ repoPrefix: "creation-distinct-drafts-" }); - try { - await gotoWorkspace(page, project.workspaceId); - for (const prompt of ["Start the first agent.", "Start the second agent."]) { - await createAgentTabFromMenu(page); - await fillComposerDraft(page, prompt); - await page.getByRole("button", { name: "Send message", exact: true }).click(); - await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible(); - await delayed.waitForDelayedCreatedStatus(); - } - await expect - .poll( - async () => - (await project.client.fetchAgents()).entries.filter( - ({ agent }) => agent.workspaceId === project.workspaceId, - ).length, - ) - .toBe(2); - delayed.release(); - } finally { - delayed.release(); - await project.cleanup(); - } + 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); }); diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts new file mode 100644 index 0000000000..e409909055 --- /dev/null +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -0,0 +1,43 @@ +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<{ + creation: Awaited>; + promptRetry: ReturnType; + agentRetries: Awaited>; + delayedCreation: Awaited>; +}>({ + 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/creation.ts b/packages/app/e2e/support/helpers/creation.ts new file mode 100644 index 0000000000..f4b94fa056 --- /dev/null +++ b/packages/app/e2e/support/helpers/creation.ts @@ -0,0 +1,234 @@ +import { expect, type Page } from "@playwright/test"; +import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +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 } 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"; + +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 function observeCreationRequests(page: Page) { + const pending = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const envelope = JSON.parse(payload.toString()) as { message?: SessionInboundMessage }; + const request = envelope.message; + if ( + request?.type === "workspace.create.request" || + request?.type === "create_agent_request" + ) { + pending.add(request.requestId); + } + }); + socket.on("framereceived", ({ payload }) => { + const envelope = JSON.parse(payload.toString()) as { message?: SessionOutboundMessage }; + const response = envelope.message; + if ( + response?.type === "workspace.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 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 envelope = JSON.parse(frame.toString()) as { message?: SessionInboundMessage }; + const request = envelope.message; + if (!repeated && request?.type === "create_agent_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({ ...envelope, message: { ...request, requestId } })); + } + return; + } + server.send(frame); + }); + server.onMessage((frame) => { + const envelope = JSON.parse(frame.toString()) as { message?: SessionOutboundMessage }; + const response = envelope.message; + if ( + response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed") && + typeof response.payload.requestId === "string" && + retryIds.delete(response.payload.requestId) + ) { + results.push(response.payload); + } + 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 = 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 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("send_agent_message_response"); + }, + async waitForDeliveredPrompt() { + await gate.waitForHeldServerMessage("send_agent_message_response"); + firstMessage = gate.getClientRequests("send_agent_message_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() { + await expect.poll(() => gate.getClientRequestCount("send_agent_message_request")).toBe(2); + expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({ + agentId: firstMessage?.agentId, + messageId: firstMessage?.messageId, + }); + await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); + }, + }; +} diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 4c020a2a10..8587fa3db6 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -250,40 +250,4 @@ describe("useDraftAgentCreateFlow", () => { }); expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); - - it("unlocks a remounted draft when the original request fails", async () => { - let rejectCreation!: (error: Error) => void; - const creation = new Promise((_resolve, reject) => { - rejectCreation = reject; - }); - const options = { - draftId: "draft-remount", - getPendingServerId: () => "server-1", - buildDraftAgent: () => ({ provider: "codex" }), - createRequest: async () => creation, - onCreateSuccess: () => undefined, - }; - const original = renderHook(() => useDraftAgentCreateFlow(options)); - let submission!: Promise; - const captureError = (error: unknown) => error; - await act(async () => { - submission = original.result.current - .handleCreateFromInput({ text: "hello", attachments: [], cwd: "/repo" }) - .catch(captureError); - }); - const pending = useCreateFlowStore.getState().pendingByDraftId[options.draftId]!; - const restored = renderHook(() => - useDraftAgentCreateFlow({ - ...options, - initialAttempt: { ...pending, timestamp: new Date(pending.timestamp) }, - }), - ); - expect(restored.result.current.isSubmitting).toBe(true); - await act(async () => { - rejectCreation(new Error("Provider unavailable")); - await submission; - }); - expect(restored.result.current.isSubmitting).toBe(false); - expect(restored.result.current.formErrorMessage).toBe("Provider unavailable"); - }); }); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 4d0cecc210..bd4d0fe95c 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -135,14 +135,17 @@ export function useDraftAgentCreateFlow({ ); const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); - // A remounted view observes completion from the same attempt as its original view. - const machine = useMemo>( - () => - localMachine.tag === "creating" && pending?.lifecycle === "abandoned" - ? { tag: "draft", errorMessage: pending.errorMessage ?? "" } - : localMachine, - [pending, localMachine], - ); + // 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); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 10314ed4de..c30aebbbd4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1068,8 +1068,6 @@ 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, 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 aceecd1dca..95a85137a5 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -35,6 +35,7 @@ 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; @@ -47,7 +48,7 @@ interface CreateFlowState { clearAll: () => void; } -export const useCreateFlowStore = create((set) => ({ +export const useCreateFlowStore = create((set, get) => ({ pendingByDraftId: {}, setPending: (pending) => set((state) => ({ @@ -59,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]; diff --git a/packages/server/src/server/request-receipts/index.test.ts b/packages/server/src/server/request-receipts/index.test.ts index dbc2f8c262..4a540e2bdc 100644 --- a/packages/server/src/server/request-receipts/index.test.ts +++ b/packages/server/src/server/request-receipts/index.test.ts @@ -180,20 +180,22 @@ test("workspace receipts serialize concurrent requests and survive reconstructio ).not.toBe("wks_first"); }); -test("a workspace persisted before a lost acknowledgement is recovered", async () => { +test("a workspace record left by failed provisioning is not mistaken for completion", async () => { const { requests, directory } = await fixture(); const workspaces = new Set(); const input = { - key: "lost-workspace", + key: "partial-workspace", workspaceId: "wks_created", request: {}, findWorkspace: async (id: string) => workspaces.has(id), create: async (id: string) => { workspaces.add(id); - throw new Error("acknowledgement lost"); + throw new Error("checkout rolled back"); }, }; - await expect(requests.createWorkspace(input)).rejects.toThrow("acknowledgement lost"); - expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_created"); + await expect(requests.createWorkspace(input)).rejects.toThrow("checkout rolled back"); + await expect(new RequestReceipts(directory).createWorkspace(input)).rejects.toThrow( + "workspace_request_outcome_unknown", + ); expect([...workspaces]).toEqual(["wks_created"]); }); diff --git a/packages/server/src/server/request-receipts/index.ts b/packages/server/src/server/request-receipts/index.ts index cbd6d71278..32948c7bcc 100644 --- a/packages/server/src/server/request-receipts/index.ts +++ b/packages/server/src/server/request-receipts/index.ts @@ -46,7 +46,9 @@ export class RequestReceipts { }): Promise { return this.execute(["create-workspace", input.key], input.request, { resource: { kind: "workspace", id: input.workspaceId }, - recover: input.findWorkspace, + // A registry entry can survive failed provisioning and checkout rollback. + // Only a completed receipt proves that the entire operation succeeded. + recover: async () => false, run: input.create, retrySafe: async (workspaceId) => !(await input.findWorkspace(workspaceId)), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bcf290cfd4..34be0cc271 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6350,19 +6350,6 @@ export class Session { request: Extract, ): Promise { try { - const create = async (workspaceId?: string) => { - 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); - }; let descriptor: WorkspaceDescriptorPayload | undefined; if (request.idempotencyKey !== undefined) { const { requestId: _requestId, idempotencyKey, ...payload } = request; @@ -6372,16 +6359,24 @@ export class Session { workspaceId: generateWorkspaceId(), findWorkspace: async (id) => (await this.workspaceRegistry.get(id)) !== null, create: async (id) => { - descriptor = await create(id); + descriptor = await this.createRequestedWorkspace(request, id); }, }); if (!descriptor) { const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace) throw new Error("Previously created workspace no longer exists"); + if (!(await this.filesystem.isDirectory(workspace.cwd))) { + throw new SessionRequestError( + "directory_not_found", + `Directory not found: ${workspace.cwd}`, + ); + } + await this.syncWorkspaceGitObserverForWorkspace(workspace); descriptor = await this.describeWorkspaceRecordWithGitData(workspace); + await this.emitCreatedWorkspaceUpdate(descriptor); } } else { - descriptor = await create(); + descriptor = await this.createRequestedWorkspace(request); } const workspace = await this.workspaceRegistry.get(descriptor.id); this.emit({ @@ -6423,6 +6418,23 @@ export class Session { } } + private async createRequestedWorkspace( + request: Extract, + 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, workspaceId?: string, 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);