mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(creation): prevent duplicate workspaces and agents on retries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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<void> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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("retrying the app's agent creation returns the same agent for every attempt", async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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("retrying after a lost first-prompt acknowledgement reuses the agent and message", async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("separate drafts can intentionally create two agents in the same workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -21,6 +21,7 @@ interface ClientRequest {
|
||||
mode?: unknown;
|
||||
path?: unknown;
|
||||
agentId?: unknown;
|
||||
messageId?: unknown;
|
||||
text?: unknown;
|
||||
activeTurnBehavior?: unknown;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
return await realpath(tmpdir());
|
||||
}
|
||||
|
||||
async function configureRemote(input: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
DaemonClient,
|
||||
CreateAgentRequestOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
/** Creation and prompt delivery have separate durable receipts on the daemon. */
|
||||
export async function createAgentWithInitialMessage(
|
||||
client: Pick<DaemonClient, "createAgent" | "sendMessage">,
|
||||
options: Omit<CreateAgentRequestOptions, "outputSchema"> & {
|
||||
idempotencyKey: string;
|
||||
clientMessageId: string;
|
||||
},
|
||||
) {
|
||||
const { initialPrompt = "", clientMessageId, images, attachments, ...creation } = options;
|
||||
const agent = await client.createAgent(creation);
|
||||
if (initialPrompt || images?.length || attachments?.length) {
|
||||
await client.sendMessage(agent.id, initialPrompt, {
|
||||
messageId: clientMessageId,
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createAgentWithInitialMessage } from "@/agent-creation";
|
||||
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";
|
||||
@@ -85,20 +85,22 @@ function buildChatDraftComposerArgs({
|
||||
async function callWorkspaceCreation({
|
||||
creationMethod,
|
||||
connectedClient,
|
||||
creationId,
|
||||
worktreeSlug,
|
||||
input,
|
||||
}: {
|
||||
creationMethod: "create_worktree" | "open_project";
|
||||
connectedClient: DaemonClient;
|
||||
creationId: string;
|
||||
worktreeSlug: string;
|
||||
input: { cwd: string };
|
||||
}) {
|
||||
if (creationMethod === "create_worktree") {
|
||||
return connectedClient.createPaseoWorktree({
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: createNameId(),
|
||||
});
|
||||
}
|
||||
return connectedClient.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
idempotencyKey: creationId,
|
||||
source:
|
||||
creationMethod === "create_worktree"
|
||||
? { kind: "worktree", cwd: input.cwd, worktreeSlug }
|
||||
: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,7 +210,7 @@ export function WorkspaceSetupDialog() {
|
||||
setErrorMessage(null);
|
||||
setCreatedWorkspace(null);
|
||||
setPendingAction(null);
|
||||
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
|
||||
}, [pendingWorkspaceSetup?.creationId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearWorkspaceSetup();
|
||||
@@ -261,6 +263,8 @@ export function WorkspaceSetupDialog() {
|
||||
const connectedClient = withConnectedClient();
|
||||
const payload = await callWorkspaceCreation({
|
||||
creationMethod: pendingWorkspaceSetup.creationMethod,
|
||||
creationId: pendingWorkspaceSetup.creationId,
|
||||
worktreeSlug: pendingWorkspaceSetup.worktreeSlug,
|
||||
connectedClient,
|
||||
input,
|
||||
});
|
||||
@@ -326,8 +330,11 @@ export function WorkspaceSetupDialog() {
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
});
|
||||
const agent = await connectedClient.createAgent(
|
||||
buildCreateAgentOptions({
|
||||
if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired"));
|
||||
const agent = await createAgentWithInitialMessage(connectedClient, {
|
||||
idempotencyKey: pendingWorkspaceSetup.creationId,
|
||||
clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`,
|
||||
...buildCreateAgentOptions({
|
||||
composerState,
|
||||
text,
|
||||
attachments: wirePayload.attachments,
|
||||
@@ -336,7 +343,7 @@ export function WorkspaceSetupDialog() {
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
provider: composerState.selectedProvider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
@@ -362,6 +369,7 @@ export function WorkspaceSetupDialog() {
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
pendingWorkspaceSetup,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
serverId,
|
||||
|
||||
@@ -250,4 +250,40 @@ 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<never>((_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<unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TDraftAgent, TCreateResult>({
|
||||
onCreateError,
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const [machine, dispatch] = useReducer(
|
||||
const [localMachine, dispatch] = useReducer(
|
||||
reducer<TDraftAgent>,
|
||||
initialAttempt,
|
||||
(attempt): DraftAgentMachineState<TDraftAgent> =>
|
||||
@@ -135,10 +134,19 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
},
|
||||
);
|
||||
|
||||
const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]);
|
||||
// A remounted view observes completion from the same attempt as its original view.
|
||||
const machine = useMemo<DraftAgentMachineState<TDraftAgent>>(
|
||||
() =>
|
||||
localMachine.tag === "creating" && pending?.lifecycle === "abandoned"
|
||||
? { tag: "draft", errorMessage: pending.errorMessage ?? "" }
|
||||
: localMachine,
|
||||
[pending, localMachine],
|
||||
);
|
||||
|
||||
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 +241,16 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
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 +265,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
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 +310,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentWithInitialMessage } from "@/agent-creation";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,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 {
|
||||
@@ -136,6 +137,7 @@ function resolveDraftModeId(input: {
|
||||
}
|
||||
|
||||
async function submitDraftCreateRequest(input: {
|
||||
draftId: string;
|
||||
attempt: { clientMessageId: string };
|
||||
text: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
@@ -195,12 +197,14 @@ async function submitDraftCreateRequest(input: {
|
||||
});
|
||||
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await requestWorkspaceDraftAgent(client, {
|
||||
const imagesData = await encodeImages(images);
|
||||
const result = await createAgentWithInitialMessage(client, {
|
||||
idempotencyKey: input.draftId,
|
||||
config,
|
||||
workspaceId,
|
||||
text,
|
||||
initialPrompt: text,
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(images ? { images } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
|
||||
});
|
||||
|
||||
@@ -389,10 +393,7 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
||||
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
|
||||
if (!pendingAutoSubmit || !pendingCreateAttempt) {
|
||||
return null;
|
||||
}
|
||||
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
|
||||
if (!pendingCreateAttempt) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -406,7 +407,7 @@ export function WorkspaceDraftAgentTab({
|
||||
? { attachments: pendingCreateAttempt.attachments }
|
||||
: {}),
|
||||
};
|
||||
}, [pendingAutoSubmit, pendingCreateAttempt]);
|
||||
}, [pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
@@ -488,6 +489,7 @@ export function WorkspaceDraftAgentTab({
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
|
||||
submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -68,7 +68,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 {
|
||||
@@ -821,6 +820,8 @@ async function createAndMergeWorkspace(input: {
|
||||
}
|
||||
|
||||
async function createMultiplicityWorkspace(input: {
|
||||
idempotencyKey: string;
|
||||
worktreeSlug: string;
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
isolation: "local" | "worktree";
|
||||
project: HostProjectListItem;
|
||||
@@ -844,12 +845,13 @@ async function createMultiplicityWorkspace(input: {
|
||||
attachments: input.attachments,
|
||||
});
|
||||
const payload = await input.client.createWorkspace({
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
source: isWorktree
|
||||
? {
|
||||
kind: "worktree",
|
||||
cwd: input.sourceDirectory,
|
||||
projectId,
|
||||
worktreeSlug: createNameId(),
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
...input.checkoutRequest,
|
||||
}
|
||||
: {
|
||||
@@ -1066,7 +1068,9 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
|
||||
initialSetup,
|
||||
} = input;
|
||||
const draftId = draftIdInput?.trim() || generateDraftId();
|
||||
const clientMessageId = generateMessageId();
|
||||
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
|
||||
if (existing?.serverId === serverId) return;
|
||||
const clientMessageId = `${draftId}:initial-message`;
|
||||
const timestamp = Date.now();
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
@@ -1634,6 +1638,10 @@ export function NewWorkspaceScreen({
|
||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= 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<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -2058,6 +2066,8 @@ export function NewWorkspaceScreen({
|
||||
: undefined;
|
||||
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
||||
? await createMultiplicityWorkspace({
|
||||
idempotencyKey: creationIdentity.draftId,
|
||||
worktreeSlug: creationIdentity.worktreeSlug,
|
||||
client: connectedClient,
|
||||
isolation: effectiveIsolation,
|
||||
project: selectedProject,
|
||||
@@ -2082,6 +2092,7 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
[
|
||||
buildCreateWorktreeInput,
|
||||
creationIdentity,
|
||||
createdWorkspace,
|
||||
effectiveIsolation,
|
||||
mergeWorkspaces,
|
||||
@@ -2134,7 +2145,7 @@ export function NewWorkspaceScreen({
|
||||
serverId: selectedServerId,
|
||||
clearDraft: chatDraft.clear,
|
||||
draftKey,
|
||||
draftId,
|
||||
draftId: creationIdentity.draftId,
|
||||
draftContextScopeKey,
|
||||
supportsForgeSearch,
|
||||
resolveClient: withConnectedClient,
|
||||
@@ -2157,7 +2168,7 @@ export function NewWorkspaceScreen({
|
||||
[
|
||||
composerState,
|
||||
draftContextScopeKey,
|
||||
draftId,
|
||||
creationIdentity,
|
||||
chatDraft.clear,
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
|
||||
@@ -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();
|
||||
@@ -35,7 +36,11 @@ interface CreateFlowState {
|
||||
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
||||
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
|
||||
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;
|
||||
@@ -67,7 +72,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||
},
|
||||
};
|
||||
}),
|
||||
markLifecycle: ({ draftId, lifecycle }) =>
|
||||
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.lifecycle === lifecycle) {
|
||||
@@ -76,7 +81,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, lifecycle },
|
||||
[draftId]: { ...current, lifecycle, errorMessage },
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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, {
|
||||
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"]);
|
||||
});
|
||||
@@ -96,8 +96,10 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
if (!matchesPendingSubmission(state.pendingByDraftId[input.draftId], input)) {
|
||||
return state;
|
||||
}
|
||||
const { [input.draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return { pendingByDraftId: rest };
|
||||
// Expo miscompiles computed member-key rest omissions; test the emitted code.
|
||||
const pendingByDraftId = { ...state.pendingByDraftId };
|
||||
delete pendingByDraftId[input.draftId];
|
||||
return { pendingByDraftId };
|
||||
});
|
||||
return pending;
|
||||
},
|
||||
|
||||
@@ -145,6 +145,8 @@ describe("workspace-setup-store", () => {
|
||||
sourceWorkspaceId: "42",
|
||||
displayName: "project",
|
||||
creationMethod: "open_project",
|
||||
creationId: expect.any(String),
|
||||
worktreeSlug: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, WorkspaceSetupSnapshot>;
|
||||
requestedKeys: Set<string>;
|
||||
surfacedFailedSetupKeys: Set<string>;
|
||||
@@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
requestedKeys: new Set(),
|
||||
surfacedFailedSetupKeys: new Set(),
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({ pendingWorkspaceSetup: value });
|
||||
set({
|
||||
pendingWorkspaceSetup: {
|
||||
...value,
|
||||
creationId: generateDraftId(),
|
||||
worktreeSlug: createNameId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
clearWorkspaceSetup: () => {
|
||||
set({ pendingWorkspaceSetup: null });
|
||||
|
||||
@@ -4333,15 +4333,24 @@ export class DaemonClient {
|
||||
input: {
|
||||
source: WorkspaceCreateRequest["source"];
|
||||
title?: string;
|
||||
idempotencyKey?: string;
|
||||
firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"];
|
||||
},
|
||||
requestId?: string,
|
||||
): Promise<WorkspaceCreatePayload> {
|
||||
// COMPAT(workspaceRequestReceipts): added in v0.7.3; 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 }
|
||||
|
||||
@@ -2563,6 +2563,7 @@ export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
export const WorkspaceCreateRequestSchema = z.object({
|
||||
type: z.literal("workspace.create.request"),
|
||||
requestId: z.string(),
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
// Optional user-set title applied to the created workspace.
|
||||
title: z.string().optional(),
|
||||
// Optional prompt context for workspace-level name/branch generation.
|
||||
@@ -3480,6 +3481,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
// COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05.
|
||||
agentRequestReceipts: z.boolean().optional(),
|
||||
workspaceRequestReceipts: z.boolean().optional(),
|
||||
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
|
||||
hubAgentRpc: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+74
-18
@@ -2,7 +2,7 @@ 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";
|
||||
import { RequestReceipts } from "./index.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -13,7 +13,7 @@ afterEach(async () => {
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
|
||||
directories.push(directory);
|
||||
return { directory, requests: new AgentRequests(directory) };
|
||||
return { directory, requests: new RequestReceipts(directory) };
|
||||
}
|
||||
|
||||
test("concurrent and reconstructed creates return the same durable agent", async () => {
|
||||
@@ -29,9 +29,9 @@ test("concurrent and reconstructed creates return the same durable agent", async
|
||||
agents.add(id);
|
||||
},
|
||||
};
|
||||
const ids = await Promise.all([requests.create(input), requests.create(input)]);
|
||||
const ids = await Promise.all([requests.createAgent(input), requests.createAgent(input)]);
|
||||
expect(ids[0]).toBe(ids[1]);
|
||||
expect(await new AgentRequests(directory).create(input)).toBe(ids[0]);
|
||||
expect(await new RequestReceipts(directory).createAgent(input)).toBe(ids[0]);
|
||||
expect(creations).toBe(1);
|
||||
});
|
||||
|
||||
@@ -47,8 +47,8 @@ test("recovers creation when the agent was persisted before acknowledgement fail
|
||||
throw new Error("acknowledgement lost");
|
||||
},
|
||||
};
|
||||
await expect(requests.create(input)).rejects.toThrow("acknowledgement lost");
|
||||
expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]);
|
||||
await expect(requests.createAgent(input)).rejects.toThrow("acknowledgement lost");
|
||||
expect(await new RequestReceipts(directory).createAgent(input)).toBe([...agents][0]);
|
||||
expect(agents.size).toBe(1);
|
||||
});
|
||||
|
||||
@@ -60,8 +60,8 @@ test("reusing a create key with different configuration is a conflict", async ()
|
||||
findAgent: async () => true,
|
||||
create: async () => {},
|
||||
};
|
||||
await requests.create(input);
|
||||
await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow(
|
||||
await requests.createAgent(input);
|
||||
await expect(requests.createAgent({ ...input, request: { model: "b" } })).rejects.toThrow(
|
||||
"agent_request_key_conflict",
|
||||
);
|
||||
});
|
||||
@@ -77,10 +77,10 @@ test("message retries survive reconstruction without submitting twice", async ()
|
||||
deliveries++;
|
||||
},
|
||||
};
|
||||
await Promise.all([requests.send(input), requests.send(input)]);
|
||||
await new AgentRequests(directory).send(input);
|
||||
await Promise.all([requests.sendMessage(input), requests.sendMessage(input)]);
|
||||
await new RequestReceipts(directory).sendMessage(input);
|
||||
expect(deliveries).toBe(1);
|
||||
await requests.send({ ...input, agentId: "another" });
|
||||
await requests.sendMessage({ ...input, agentId: "another" });
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
@@ -96,8 +96,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
throw new Error("connection lost");
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("connection lost");
|
||||
await expect(new AgentRequests(directory).send(input)).rejects.toThrow(
|
||||
await expect(requests.sendMessage(input)).rejects.toThrow("connection lost");
|
||||
await expect(new RequestReceipts(directory).sendMessage(input)).rejects.toThrow(
|
||||
"agent_request_outcome_unknown",
|
||||
);
|
||||
expect(deliveries).toBe(1);
|
||||
@@ -114,9 +114,9 @@ test("a creation failure with no stored agent can be retried", async () => {
|
||||
if (!available) throw new Error("provider unavailable");
|
||||
},
|
||||
};
|
||||
await expect(requests.create(input)).rejects.toThrow("provider unavailable");
|
||||
await expect(requests.createAgent(input)).rejects.toThrow("provider unavailable");
|
||||
available = true;
|
||||
await expect(requests.create(input)).resolves.toEqual(expect.any(String));
|
||||
await expect(requests.createAgent(input)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("failed local message preparation does not leave an ambiguous receipt", async () => {
|
||||
@@ -134,10 +134,66 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
sends++;
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("load failed");
|
||||
await expect(requests.sendMessage(input)).rejects.toThrow("load failed");
|
||||
available = true;
|
||||
await new AgentRequests(directory).send(input);
|
||||
await new RequestReceipts(directory).sendMessage(input);
|
||||
available = false;
|
||||
await requests.send(input);
|
||||
await requests.sendMessage(input);
|
||||
expect(sends).toBe(1);
|
||||
});
|
||||
|
||||
test("workspace receipts serialize concurrent requests and survive reconstruction", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const workspaces = new Set<string>();
|
||||
let creations = 0;
|
||||
const input = {
|
||||
key: "workspace",
|
||||
workspaceId: "wks_first",
|
||||
request: { source: { kind: "directory", path: "/project" } },
|
||||
findWorkspace: async (id: string) => workspaces.has(id),
|
||||
create: async (id: string) => {
|
||||
creations++;
|
||||
workspaces.add(id);
|
||||
},
|
||||
};
|
||||
expect(
|
||||
await Promise.all([
|
||||
requests.createWorkspace(input),
|
||||
requests.createWorkspace({ ...input, workspaceId: "wks_second" }),
|
||||
]),
|
||||
).toEqual(["wks_first", "wks_first"]);
|
||||
expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_first");
|
||||
expect(creations).toBe(1);
|
||||
await expect(
|
||||
requests.createWorkspace({
|
||||
...input,
|
||||
request: { source: { kind: "directory", path: "/other" } },
|
||||
}),
|
||||
).rejects.toThrow("workspace_request_key_conflict");
|
||||
expect(
|
||||
await requests.createAgent({
|
||||
key: input.key,
|
||||
request: {},
|
||||
findAgent: async () => false,
|
||||
create: async () => {},
|
||||
}),
|
||||
).not.toBe("wks_first");
|
||||
});
|
||||
|
||||
test("a workspace persisted before a lost acknowledgement is recovered", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const workspaces = new Set<string>();
|
||||
const input = {
|
||||
key: "lost-workspace",
|
||||
workspaceId: "wks_created",
|
||||
request: {},
|
||||
findWorkspace: async (id: string) => workspaces.has(id),
|
||||
create: async (id: string) => {
|
||||
workspaces.add(id);
|
||||
throw new Error("acknowledgement lost");
|
||||
},
|
||||
};
|
||||
await expect(requests.createWorkspace(input)).rejects.toThrow("acknowledgement lost");
|
||||
expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_created");
|
||||
expect([...workspaces]).toEqual(["wks_created"]);
|
||||
});
|
||||
+66
-26
@@ -2,36 +2,57 @@ 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";
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
|
||||
const ReceiptSchema = z.object({
|
||||
const ReceiptStateSchema = z.object({
|
||||
fingerprint: z.string(),
|
||||
agentId: z.string(),
|
||||
state: z.enum(["pending", "completed"]),
|
||||
});
|
||||
type Receipt = z.infer<typeof ReceiptSchema>;
|
||||
// Preserve existing agent receipt files while giving workspaces their own identity.
|
||||
const ReceiptSchema = z.union([
|
||||
ReceiptStateSchema.extend({ agentId: z.string() }),
|
||||
ReceiptStateSchema.extend({ workspaceId: z.string() }),
|
||||
]);
|
||||
type Receipt = z.infer<typeof ReceiptStateSchema> & {
|
||||
resource: { kind: "agent" | "workspace"; id: string };
|
||||
};
|
||||
|
||||
/** One daemon-owned request journal, shared by all of its socket sessions. */
|
||||
export class AgentRequests {
|
||||
export class RequestReceipts {
|
||||
private readonly pending = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
create(input: {
|
||||
createAgent(input: {
|
||||
key: string;
|
||||
request: unknown;
|
||||
findAgent: (agentId: string) => Promise<boolean>;
|
||||
create: (agentId: string) => Promise<void>;
|
||||
}): Promise<string> {
|
||||
return this.execute(["create", input.key], input.request, {
|
||||
agentId: randomUUID(),
|
||||
resource: { kind: "agent", id: randomUUID() },
|
||||
recover: input.findAgent,
|
||||
run: input.create,
|
||||
retrySafe: async (agentId) => !(await input.findAgent(agentId)),
|
||||
});
|
||||
}
|
||||
|
||||
async send(input: {
|
||||
createWorkspace(input: {
|
||||
key: string;
|
||||
request: unknown;
|
||||
workspaceId: string;
|
||||
findWorkspace: (workspaceId: string) => Promise<boolean>;
|
||||
create: (workspaceId: string) => Promise<void>;
|
||||
}): Promise<string> {
|
||||
return this.execute(["create-workspace", input.key], input.request, {
|
||||
resource: { kind: "workspace", id: input.workspaceId },
|
||||
recover: input.findWorkspace,
|
||||
run: input.create,
|
||||
retrySafe: async (workspaceId) => !(await input.findWorkspace(workspaceId)),
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(input: {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
@@ -39,7 +60,7 @@ export class AgentRequests {
|
||||
prepare?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await this.execute(["send", input.agentId, input.messageId], input.request, {
|
||||
agentId: input.agentId,
|
||||
resource: { kind: "agent", id: 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,
|
||||
@@ -52,7 +73,7 @@ export class AgentRequests {
|
||||
identity: string[],
|
||||
request: unknown,
|
||||
operation: {
|
||||
agentId: string;
|
||||
resource: Receipt["resource"];
|
||||
recover: (agentId: string) => Promise<boolean>;
|
||||
run: (agentId: string) => Promise<void>;
|
||||
prepare?: (() => Promise<void>) | undefined;
|
||||
@@ -79,7 +100,7 @@ export class AgentRequests {
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
operation: {
|
||||
agentId: string;
|
||||
resource: Receipt["resource"];
|
||||
recover: (agentId: string) => Promise<boolean>;
|
||||
run: (agentId: string) => Promise<void>;
|
||||
prepare?: (() => Promise<void>) | undefined;
|
||||
@@ -89,39 +110,58 @@ export class AgentRequests {
|
||||
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");
|
||||
if (existing.fingerprint !== fingerprint)
|
||||
throw new Error(`${operation.resource.kind}_request_key_conflict`);
|
||||
if (existing.state === "completed") return existing.resource.id;
|
||||
if (!(await operation.recover(existing.resource.id))) {
|
||||
throw new Error(`${operation.resource.kind}_request_outcome_unknown`);
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
|
||||
return existing.agentId;
|
||||
await writeReceipt(file, { ...existing, state: "completed" });
|
||||
return existing.resource.id;
|
||||
}
|
||||
await operation.prepare?.();
|
||||
const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" };
|
||||
await writeJsonFileAtomic(file, receipt);
|
||||
const receipt: Receipt = { fingerprint, resource: operation.resource, state: "pending" };
|
||||
await writeReceipt(file, receipt);
|
||||
try {
|
||||
await operation.run(receipt.agentId);
|
||||
await operation.run(receipt.resource.id);
|
||||
} 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 });
|
||||
// Once creation cleanup finished, absence of its persisted resource
|
||||
// confirms that retrying cannot duplicate it.
|
||||
if (await operation.retrySafe?.(receipt.resource.id)) await rm(file, { force: true });
|
||||
throw error;
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
return receipt.agentId;
|
||||
await writeReceipt(file, { ...receipt, state: "completed" });
|
||||
return receipt.resource.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function readReceipt(file: string): Promise<Receipt | null> {
|
||||
try {
|
||||
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
const stored = ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
return {
|
||||
fingerprint: stored.fingerprint,
|
||||
state: stored.state,
|
||||
resource:
|
||||
"agentId" in stored
|
||||
? { kind: "agent", id: stored.agentId }
|
||||
: { kind: "workspace", id: stored.workspaceId },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeReceipt(file: string, receipt: Receipt): Promise<void> {
|
||||
await writeJsonFileAtomic(file, {
|
||||
fingerprint: receipt.fingerprint,
|
||||
state: receipt.state,
|
||||
...(receipt.resource.kind === "agent"
|
||||
? { agentId: receipt.resource.id }
|
||||
: { workspaceId: receipt.resource.id }),
|
||||
});
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } 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 +364,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
const sessionOptions: SessionOptions = {
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: options.clientId ?? "test-client",
|
||||
onMessage: (message) => messages.push(message),
|
||||
...(options.targetedMessages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { RequestReceipts } from "./request-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";
|
||||
@@ -147,6 +147,7 @@ import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
checkoutFromPersistedWorkspacePlacement,
|
||||
deriveWorkspaceDisplayName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
||||
import {
|
||||
@@ -458,7 +459,7 @@ export interface SessionOptions {
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
requestReceipts: Pick<RequestReceipts, "createAgent" | "createWorkspace" | "sendMessage">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
directorySync?: DirectorySyncService;
|
||||
@@ -780,7 +781,10 @@ export class Session {
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly hubExecutionController: HubExecutionController | null;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
private readonly requestReceipts: Pick<
|
||||
RequestReceipts,
|
||||
"createAgent" | "createWorkspace" | "sendMessage"
|
||||
>;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
@@ -855,7 +859,7 @@ export class Session {
|
||||
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
|
||||
this.pushNotifications = pushNotifications;
|
||||
this.paseoHome = paseoHome;
|
||||
this.agentRequests = options.agentRequests;
|
||||
this.requestReceipts = options.requestReceipts;
|
||||
this.projectIcons = new ProjectIconReader(paseoHome);
|
||||
this.worktreesRoot = worktreesRoot;
|
||||
this.pluginRuntime = pluginRuntime;
|
||||
@@ -3826,7 +3830,7 @@ export class Session {
|
||||
throw new Error("Idempotent creation requires sending the initial prompt separately");
|
||||
}
|
||||
const { requestId: _requestId, idempotencyKey, ...request } = msg;
|
||||
const id = await this.agentRequests.create({
|
||||
const id = await this.requestReceipts.createAgent({
|
||||
key: idempotencyKey,
|
||||
request,
|
||||
findAgent: async (agentId) =>
|
||||
@@ -6346,23 +6350,67 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (this.pluginRuntime) {
|
||||
const { type, requestId, ...input } = request;
|
||||
const transformed = await this.pluginRuntime.before("workspace.create", input);
|
||||
request = { ...transformed, type, requestId };
|
||||
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;
|
||||
const workspaceId = await this.requestReceipts.createWorkspace({
|
||||
key: idempotencyKey,
|
||||
request: payload,
|
||||
workspaceId: generateWorkspaceId(),
|
||||
findWorkspace: async (id) => (await this.workspaceRegistry.get(id)) !== null,
|
||||
create: async (id) => {
|
||||
descriptor = await create(id);
|
||||
},
|
||||
});
|
||||
if (!descriptor) {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) throw new Error("Previously created workspace no longer exists");
|
||||
descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||
}
|
||||
} else {
|
||||
descriptor = await create();
|
||||
|
||||
}
|
||||
if (request.source.kind === "directory") {
|
||||
await this.handleWorkspaceCreateLocal(request);
|
||||
return;
|
||||
}
|
||||
await this.handleWorkspaceCreateWorktree(request);
|
||||
const workspace = await this.workspaceRegistry.get(descriptor.id);
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: descriptor,
|
||||
setupTerminalId: null,
|
||||
...(workspace?.untrustedSource
|
||||
? {
|
||||
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
|
||||
workspace.untrustedSource,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} 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;
|
||||
const errorCode =
|
||||
error instanceof WorkspaceProvisioningError || error instanceof SessionRequestError
|
||||
? error.code
|
||||
: undefined;
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
@@ -6378,25 +6426,16 @@ export class Session {
|
||||
|
||||
private async handleWorkspaceCreateLocal(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
workspaceId?: string,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
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;
|
||||
@@ -6405,19 +6444,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,
|
||||
@@ -6441,29 +6471,24 @@ export class Session {
|
||||
{ currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) },
|
||||
);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateWorktree(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
workspaceId?: string,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
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);
|
||||
@@ -6471,6 +6496,7 @@ export class Session {
|
||||
const result = await this.createPaseoWorktreeWorkflow(
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
workspaceId,
|
||||
projectId: source.projectId,
|
||||
worktreeSlug: source.worktreeSlug,
|
||||
action: source.action,
|
||||
@@ -6487,26 +6513,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(
|
||||
@@ -7837,6 +7848,19 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareAgentMessage(agentId: string, text: string): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -7887,16 +7911,12 @@ export class Session {
|
||||
}
|
||||
};
|
||||
if (msg.messageId) {
|
||||
await this.agentRequests.send({
|
||||
await this.requestReceipts.sendMessage({
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } 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 +185,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } 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 +89,7 @@ function createHarness(input: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } from "./test-utils/session-stubs.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
@@ -642,7 +642,7 @@ function createSessionForWorkspaceTests(
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: options.appVersion ?? null,
|
||||
@@ -1003,7 +1003,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
serverId: "test-server",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
@@ -1157,7 +1157,7 @@ test("create_agent_request launches from an exact subdirectory in a created work
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
@@ -1295,7 +1295,7 @@ test("create_agent_request does not title an existing workspace from the agent p
|
||||
let generateCalls = 0;
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
@@ -1565,7 +1565,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -2048,7 +2048,7 @@ 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(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -2217,7 +2217,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -2377,7 +2377,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
const killTerminalBestEffort = vi.fn();
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -3649,7 +3649,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
|
||||
+5
-4
@@ -40,6 +40,7 @@ export interface ImportWorkspaceResult<T> {
|
||||
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<PersistedWorkspaceRecord>;
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } from "./test-utils/session-stubs.js";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
@@ -100,7 +100,7 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
|
||||
const session = asInternals<SessionInternals>(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage,
|
||||
|
||||
@@ -281,14 +281,18 @@ export function createProviderSnapshotManagerStub(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentRequestsStub(): SessionOptions["agentRequests"] {
|
||||
export function createRequestReceiptsStub(): SessionOptions["requestReceipts"] {
|
||||
return {
|
||||
async create(input) {
|
||||
async createAgent(input) {
|
||||
const agentId = randomUUID();
|
||||
await input.create(agentId);
|
||||
return agentId;
|
||||
},
|
||||
send: (input) => input.send(),
|
||||
async createWorkspace(input) {
|
||||
await input.create(input.workspaceId);
|
||||
return input.workspaceId;
|
||||
},
|
||||
sendMessage: (input) => input.send(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { RequestReceipts } from "./request-receipts/index.js";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||
import { join } from "path";
|
||||
@@ -529,7 +529,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly agentRequests: AgentRequests;
|
||||
private readonly requestReceipts: RequestReceipts;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceLabelService: WorkspaceLabelService | null;
|
||||
@@ -649,7 +649,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.requestReceipts = new RequestReceipts(join(paseoHome, "agent-requests"));
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.workspaceLabelService = workspaceLabelService ?? null;
|
||||
@@ -1425,7 +1425,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentRequests: this.agentRequests,
|
||||
requestReceipts: this.requestReceipts,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceLabelService: this.workspaceLabelService ?? undefined,
|
||||
@@ -1646,6 +1646,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
features: {
|
||||
ownedSubscriptions: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
hubAgentRpc: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { createRequestReceiptsStub } from "./test-utils/session-stubs.js";
|
||||
import pino from "pino";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -208,7 +208,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
];
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
requestReceipts: createRequestReceiptsStub(),
|
||||
clientId: "wire-compat-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
clientCapabilities: options?.clientCapabilities ?? null,
|
||||
|
||||
Reference in New Issue
Block a user