From 7c85341a426f32b81b1f8bd4426900cf9780aea7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 3 May 2026 23:22:54 +0700 Subject: [PATCH] Fix worktree creation and archive flow Server: drop the synchronous branch-name generator dep, use the worktree slug as the initial branch, and move first-agent branch auto-naming to an async post-creation step backed by a structured LLM call. Guards against renaming when the placeholder branch has already been changed. App: make workspace and worktree archive optimistic, rolling back the local hide if the daemon call fails, and suppress incoming workspace upserts while an archive is pending. --- package-lock.json | 1 + .../src/components/sidebar-workspace-list.tsx | 97 ++++++++++----- packages/app/src/contexts/session-context.tsx | 21 ++-- .../session-workspace-upserts.test.ts | 96 +++++++++++---- .../src/contexts/session-workspace-upserts.ts | 99 +++++++++++++-- .../src/hooks/use-sidebar-workspaces-list.ts | 4 + .../stores/checkout-git-actions-store.test.ts | 38 ++++++ .../src/stores/checkout-git-actions-store.ts | 33 ++++- packages/app/src/stores/session-store.ts | 9 +- packages/server/package.json | 1 + .../agent/agent-metadata-generator.test.ts | 2 +- .../src/server/agent/mcp-server.test.ts | 4 +- packages/server/src/server/bootstrap.ts | 4 +- .../daemon-e2e/checkout-ship.e2e.test.ts | 2 +- .../src/server/paseo-worktree-service.test.ts | 113 +++++++++++++----- .../src/server/paseo-worktree-service.ts | 79 +++++------- .../resolve-worktree-creation-intent.test.ts | 10 +- .../resolve-worktree-creation-intent.ts | 32 +++-- packages/server/src/server/session.ts | 33 ++++- .../src/server/worktree-bootstrap.test.ts | 2 +- .../worktree-branch-name-generator.test.ts | 63 ++++++++++ .../server/worktree-branch-name-generator.ts | 81 +++++++++++++ .../server/src/server/worktree-core.test.ts | 34 ++++-- packages/server/src/server/worktree-core.ts | 68 +++++++---- .../src/server/worktree-session.test.ts | 10 +- .../server/src/server/worktree-session.ts | 10 ++ .../server/src/utils/checkout-git.test.ts | 2 +- packages/server/src/utils/worktree.test.ts | 10 +- packages/server/src/utils/worktree.ts | 4 +- 29 files changed, 733 insertions(+), 229 deletions(-) create mode 100644 packages/server/src/server/worktree-branch-name-generator.test.ts create mode 100644 packages/server/src/server/worktree-branch-name-generator.ts diff --git a/package-lock.json b/package-lock.json index e15e5b93f..b0ce5aee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38762,6 +38762,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a013a4673..b26937f4b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -104,9 +104,14 @@ import { import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; +import { + clearWorkspaceArchivePending, + markWorkspaceArchivePending, +} from "@/contexts/session-workspace-upserts"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceExecutionDirectory, + resolveWorkspaceMapKeyByIdentity, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; @@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; + +function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null { + const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceId: workspace.workspaceId, + }); + const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; + markWorkspaceArchivePending({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); + return snapshot; +} + +function restoreOptimisticallyHiddenWorkspace(input: { + serverId: string; + workspaceId: string; + snapshot: WorkspaceDescriptor | null; +}): void { + clearWorkspaceArchivePending({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + if (input.snapshot) { + useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]); + } +} const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; @@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({ toast.error(message); }); })(); - }, [ - archiveWorktree, - isArchiving, - redirectAfterArchive, - toast, - workspace.name, - workspace.workspaceDirectory, - workspace.serverId, - workspace.workspaceId, - ]); + }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { @@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({ } setIsArchivingWorkspace(true); + const snapshot = hideWorkspaceOptimistically(workspace); redirectAfterArchive(); void (async () => { @@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({ throw new Error(payload.error); } } catch (error) { + restoreOptimisticallyHiddenWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + snapshot, + }); toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); })(); - }, [ - isArchivingWorkspace, - redirectAfterArchive, - toast, - workspace.name, - workspace.serverId, - workspace.workspaceId, - ]); + }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; @@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({ } setIsArchivingWorkspace(true); + const snapshot = hideWorkspaceOptimistically(workspace); redirectAfterArchive(); void (async () => { @@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({ throw new Error(payload.error); } } catch (error) { + restoreOptimisticallyHiddenWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + snapshot, + }); toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); })(); - }, [ - isArchivingWorkspace, - redirectAfterArchive, - toast, - workspace.name, - workspace.serverId, - workspace.workspaceId, - ]); + }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); return ( <> @@ -2120,13 +2144,28 @@ function ProjectBlock({ } setIsRemovingProject(true); + const snapshots = new Map( + project.workspaces.map((workspace) => [ + workspace.workspaceId, + hideWorkspaceOptimistically(workspace), + ]), + ); const isRejected = (r: PromiseSettledResult) => r.status === "rejected"; void Promise.allSettled( project.workspaces.map(async (ws) => { - const payload = await client.archiveWorkspace(ws.workspaceId); - if (payload.error) { - throw new Error(payload.error); + try { + const payload = await client.archiveWorkspace(ws.workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + } catch (error) { + restoreOptimisticallyHiddenWorkspace({ + serverId, + workspaceId: ws.workspaceId, + snapshot: snapshots.get(ws.workspaceId) ?? null, + }); + throw error; } }), ).then((results) => { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index fa14527c1..3632c479f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -42,7 +42,6 @@ import { normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; -import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; @@ -60,7 +59,10 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; -import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts"; +import { + clearWorkspaceArchivePending, + shouldSuppressWorkspaceForLocalArchive, +} from "@/contexts/session-workspace-upserts"; import { isNative } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; @@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider for (const entry of payload.entries) { const workspace = normalizeWorkspaceDescriptor(entry); + if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { + continue; + } workspaces.set(workspace.id, workspace); } @@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { + clearWorkspaceArchivePending({ + serverId, + workspaceId: String(message.payload.id), + }); removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) }); removeWorkspace(serverId, String(message.payload.id)); return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); - if ( - shouldSuppressWorkspaceUpsertForLocalArchive({ - serverId, - workspace, - isArchivePending: isLocalWorktreeArchivePending, - }) - ) { + if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { return; } mergeWorkspaces(serverId, [workspace]); diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index 39c67e110..a976ef840 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts"; +import { + clearWorkspaceArchivePending, + isWorkspaceArchivePending, + markWorkspaceArchivePending, + shouldSuppressWorkspaceForLocalArchive, +} from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { id: "/repo/worktree", @@ -21,40 +26,83 @@ function workspace(input?: Partial): WorkspaceDescriptor { return { ...baseWorkspace, ...input }; } -describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => { - it("suppresses archiving upserts for a locally pending archive", () => { - const isArchivePending = vi.fn(() => true); +describe("workspace archive pending suppression", () => { + it("tracks a locally pending workspace archive by id and directory", () => { + markWorkspaceArchivePending({ + serverId: "server-1", + workspaceId: "/repo/worktree", + workspaceDirectory: "/repo/worktree", + }); expect( - shouldSuppressWorkspaceUpsertForLocalArchive({ + isWorkspaceArchivePending({ serverId: "server-1", - workspace: workspace({ workspaceDirectory: "/repo/worktree" }), - isArchivePending, + workspaceId: "/repo/worktree", }), ).toBe(true); - expect(isArchivePending).toHaveBeenCalledWith({ - serverId: "server-1", - cwd: "/repo/worktree", - }); - }); - - it("allows archiving upserts when this client did not start the archive", () => { expect( - shouldSuppressWorkspaceUpsertForLocalArchive({ + isWorkspaceArchivePending({ serverId: "server-1", - workspace: workspace(), - isArchivePending: () => false, + workspaceDirectory: "/repo/worktree", }), - ).toBe(false); - }); - - it("allows normal upserts while a local archive is pending", () => { + ).toBe(true); expect( - shouldSuppressWorkspaceUpsertForLocalArchive({ + shouldSuppressWorkspaceForLocalArchive({ serverId: "server-1", workspace: workspace({ archivingAt: null }), - isArchivePending: () => true, + }), + ).toBe(true); + + clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); + + expect( + isWorkspaceArchivePending({ + serverId: "server-1", + workspaceId: "/repo/worktree", }), ).toBe(false); }); + + it("suppresses upserts for a locally pending archive", () => { + markWorkspaceArchivePending({ + serverId: "server-1", + workspaceId: "/repo/worktree", + workspaceDirectory: "/repo/worktree", + }); + + expect( + shouldSuppressWorkspaceForLocalArchive({ + serverId: "server-1", + workspace: workspace({ workspaceDirectory: "/repo/worktree" }), + }), + ).toBe(true); + + clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); + }); + + it("allows upserts when this client did not start the archive", () => { + expect( + shouldSuppressWorkspaceForLocalArchive({ + serverId: "server-1", + workspace: workspace(), + }), + ).toBe(false); + }); + + it("suppresses stale normal upserts while a local archive is pending", () => { + markWorkspaceArchivePending({ + serverId: "server-1", + workspaceId: "/repo/worktree", + workspaceDirectory: "/repo/worktree", + }); + + expect( + shouldSuppressWorkspaceForLocalArchive({ + serverId: "server-1", + workspace: workspace({ archivingAt: null }), + }), + ).toBe(true); + + clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); + }); }); diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts index 55c123c30..9c77198c5 100644 --- a/packages/app/src/contexts/session-workspace-upserts.ts +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -1,15 +1,96 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity"; -export function shouldSuppressWorkspaceUpsertForLocalArchive(input: { +interface PendingWorkspaceArchive { + workspaceId: string; + workspaceDirectory: string | null; +} + +const pendingWorkspaceArchivesByServer = new Map>(); + +function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string { + return `${input.serverId.trim()}::${input.workspaceId.trim()}`; +} + +export function markWorkspaceArchivePending(input: { + serverId: string; + workspaceId: string; + workspaceDirectory?: string | null; +}): void { + const serverId = input.serverId.trim(); + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!serverId || !workspaceId) { + return; + } + + const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map(); + archives.set(pendingArchiveKey({ serverId, workspaceId }), { + workspaceId, + workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory), + }); + pendingWorkspaceArchivesByServer.set(serverId, archives); +} + +export function clearWorkspaceArchivePending(input: { + serverId: string; + workspaceId: string; +}): void { + const serverId = input.serverId.trim(); + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!serverId || !workspaceId) { + return; + } + + const archives = pendingWorkspaceArchivesByServer.get(serverId); + if (!archives) { + return; + } + archives.delete(pendingArchiveKey({ serverId, workspaceId })); + if (archives.size === 0) { + pendingWorkspaceArchivesByServer.delete(serverId); + } +} + +export function isWorkspaceArchivePending(input: { + serverId: string; + workspaceId?: string | null; + workspaceDirectory?: string | null; +}): boolean { + const serverId = input.serverId.trim(); + if (!serverId) { + return false; + } + + const archives = pendingWorkspaceArchivesByServer.get(serverId); + if (!archives) { + return false; + } + + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) { + return true; + } + + const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory); + if (!workspaceDirectory) { + return false; + } + + for (const archive of archives.values()) { + if (archive.workspaceDirectory === workspaceDirectory) { + return true; + } + } + return false; +} + +export function shouldSuppressWorkspaceForLocalArchive(input: { serverId: string; workspace: WorkspaceDescriptor; - isArchivePending: (params: { serverId: string; cwd: string }) => boolean; }): boolean { - return ( - input.workspace.archivingAt !== null && - input.isArchivePending({ - serverId: input.serverId, - cwd: input.workspace.workspaceDirectory, - }) - ); + return isWorkspaceArchivePending({ + serverId: input.serverId, + workspaceId: input.workspace.id, + workspaceDirectory: input.workspace.workspaceDirectory, + }); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index ef7ef5636..feffcdda8 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -11,6 +11,7 @@ import { } from "@/stores/session-store-hooks"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; const EMPTY_ORDER: string[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; @@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: { }); for (const entry of payload.entries) { const workspace = toWorkspaceDescriptor(entry); + if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { + continue; + } next.set(workspace.id, workspace); } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/stores/checkout-git-actions-store.test.ts index 05aa0282c..5659763ef 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/stores/checkout-git-actions-store.test.ts @@ -10,6 +10,10 @@ import { isLocalWorktreeArchivePending, useCheckoutGitActionsStore, } from "@/stores/checkout-git-actions-store"; +import { + clearWorkspaceArchivePending, + isWorkspaceArchivePending, +} from "@/contexts/session-workspace-upserts"; vi.mock("@react-native-async-storage/async-storage", () => ({ default: { @@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => { beforeEach(() => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); + clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); + clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" }); appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => { afterEach(() => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); + clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); + clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" }); appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => { deferred.resolve({}); await archive; + + expect( + isWorkspaceArchivePending({ + serverId, + workspaceId: cwd, + }), + ).toBe(true); + }); + + it("hides an archived worktree when the workspace map is keyed by opaque id", async () => { + const deferred = createDeferred>(); + const client = { + archivePaseoWorktree: vi.fn(() => deferred.promise), + }; + const featureWorkspace = workspace({ + id: "ws-feature", + name: "feature", + workspaceDirectory: cwd, + }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]])); + + const archive = useCheckoutGitActionsStore + .getState() + .archiveWorktree({ serverId, cwd, worktreePath: cwd }); + + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false); + + deferred.resolve({}); + await archive; }); it("restores an optimistically hidden worktree when archive fails", async () => { diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 0f631bc48..d06a847f4 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -8,6 +8,14 @@ import { import { useSessionStore } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; +import { + clearWorkspaceArchivePending, + markWorkspaceArchivePending, +} from "@/contexts/session-workspace-upserts"; +import { + resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceMapKeyByIdentity, +} from "@/utils/workspace-execution"; const SUCCESS_DISPLAY_MS = 1000; @@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: { serverId: string; worktreePath: string; }): WorktreeArchiveSnapshot { + const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces; + const workspaceId = + resolveWorkspaceIdByExecutionDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: input.worktreePath, + }) ?? input.worktreePath; + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId }); return { - workspace: - useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ?? - null, + workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null, worktreeLists: appQueryClient.getQueriesData({ predicate: (query) => isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }), @@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create() run: async () => { const client = resolveClient(serverId); const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath }); + markWorkspaceArchivePending({ + serverId, + workspaceId: snapshot.workspace?.id ?? worktreePath, + workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath, + }); removeWorktreeFromCachedLists({ serverId, worktreePath }); - removeWorktreeFromSessionStore({ serverId, worktreePath }); + removeWorktreeFromSessionStore({ + serverId, + worktreePath: snapshot.workspace?.id ?? worktreePath, + }); try { const payload = await client.archivePaseoWorktree({ worktreePath }); if (payload.error) { throw new Error(payload.error.message); } } catch (error) { + clearWorkspaceArchivePending({ + serverId, + workspaceId: snapshot.workspace?.id ?? worktreePath, + }); restoreWorktreeArchiveState({ serverId, snapshot }); throw error; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index ef4329fe7..59aa8552a 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -23,6 +23,7 @@ import type { WorkspaceDescriptorPayload, } from "@server/shared/messages"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; import { createAgentLastActivityCoalescer, type AgentLastActivityCommitter, @@ -1114,11 +1115,15 @@ export const useSessionStore = create()( removeWorkspace: (serverId, workspaceId) => { set((prev) => { const session = prev.sessions[serverId]; - if (!session || !session.workspaces.has(workspaceId)) { + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces: session?.workspaces, + workspaceId, + }); + if (!session || !workspaceKey) { return prev; } const next = new Map(session.workspaces); - next.delete(workspaceId); + next.delete(workspaceKey); return { ...prev, sessions: { diff --git a/packages/server/package.json b/packages/server/package.json index 4d0e863a0..82020411e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -74,6 +74,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", diff --git a/packages/server/src/server/agent/agent-metadata-generator.test.ts b/packages/server/src/server/agent/agent-metadata-generator.test.ts index beaa00296..be619b148 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.test.ts @@ -38,7 +38,7 @@ function createLegacyWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 1841f9cad..1c3587918 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -21,7 +21,6 @@ import { type CreatePaseoWorktreeInput, } from "../paseo-worktree-service.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js"; -import { createWorktreeCoreDeps } from "../worktree-core.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import type { GitHubService } from "../../services/github-service.js"; @@ -307,9 +306,8 @@ function createPaseoWorktreeForMcpTest(options: { return async (input, serviceOptions) => { options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); - const coreDeps = createWorktreeCoreDeps(github); const result = await createPaseoWorktreeService(input, { - ...coreDeps, + github, ...(serviceOptions?.resolveDefaultBranch ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } : {}), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3f8e9c8be..03fd6282d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -90,7 +90,6 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { createGitHubService } from "../services/github-service.js"; import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; -import { createWorktreeCoreDeps } from "./worktree-core.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; @@ -575,9 +574,8 @@ export async function createPaseoDaemon( { paseoHome: config.paseoHome, createPaseoWorktree: async (workflowInput, workflowOptions) => { - const coreDeps = createWorktreeCoreDeps(github); return createRegisteredPaseoWorktree(workflowInput, { - ...coreDeps, + github, ...(workflowOptions?.resolveDefaultBranch ? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch, diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index af6ffda0a..517c08365 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -33,7 +33,7 @@ function createLegacyWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 4f442ed81..5579d19d6 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -12,7 +12,6 @@ import { createPaseoWorktree, type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; -import { createWorktreeCoreDeps } from "./worktree-core.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; const cleanupPaths: string[] = []; @@ -105,32 +104,32 @@ test("reuses an existing worktree and still upserts the workspace", async () => test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); - const deps = createDeps({ - generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"), - }); + const deps = createDeps(); const created = await createPaseoWorktree( { cwd: repoDir, + worktreeSlug: "dazzling-yak", runSetup: false, paseoHome: path.join(tempDir, ".paseo"), }, deps, ); - expect(created.worktree.branchName).toBe("unnamed-placeholder"); + expect(created.worktree.branchName).toBe("dazzling-yak"); expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "pending", - placeholderBranchName: "unnamed-placeholder", + placeholderBranchName: "dazzling-yak", }, }); const first = await attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, firstAgentContext: { prompt: "Build the agent context name" }, - generateBranchName: deps.generateBranchName, + generateBranchNameFromContext: async ({ firstAgentContext }) => + firstAgentContext.prompt ? "renamed-from-agent-context" : null, }); const branchAfterFirst = execSync("git branch --show-current", { cwd: created.worktree.worktreePath, @@ -149,14 +148,14 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex version: 2, firstAgentBranchAutoName: { status: "attempted", - placeholderBranchName: "unnamed-placeholder", + placeholderBranchName: "dazzling-yak", }, }); const second = await attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, firstAgentContext: { prompt: "Try another name" }, - generateBranchName: () => "second-agent-name", + generateBranchNameFromContext: async () => "second-agent-name", }); const branchAfterSecond = execSync("git branch --show-current", { cwd: created.worktree.worktreePath, @@ -172,10 +171,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex test("renames the branch even when the app supplies a random placeholder slug", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); - const deps = createDeps({ - generateBranchName: (seed) => - seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"), - }); + const deps = createDeps(); const created = await createPaseoWorktree( { @@ -188,6 +184,18 @@ test("renames the branch even when the app supplies a random placeholder slug", deps, ); + expect(created.worktree.branchName).toBe("dazzling-yak"); + expect(created.workspace.displayName).toBe("dazzling-yak"); + + await attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + firstAgentContext: { prompt: "Investigate the failing login flow" }, + generateBranchNameFromContext: async ({ firstAgentContext }) => + firstAgentContext.prompt === "Investigate the failing login flow" + ? "renamed-from-prompt" + : null, + }); + const branchAfter = execSync("git branch --show-current", { cwd: created.worktree.worktreePath, stdio: "pipe", @@ -195,20 +203,13 @@ test("renames the branch even when the app supplies a random placeholder slug", .toString() .trim(); - expect(created.worktree.branchName).toBe("renamed-from-prompt"); expect(branchAfter).toBe("renamed-from-prompt"); - expect(created.workspace.displayName).toBe("renamed-from-prompt"); }); test("renames the branch from a github_pr attachment when no prompt is supplied", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); - const deps = createDeps({ - generateBranchName: (seed) => - seed?.includes("Investigate flaky checkout test") - ? "renamed-from-pr-attachment" - : (seed ?? "fallback"), - }); + const deps = createDeps(); const created = await createPaseoWorktree( { @@ -231,6 +232,27 @@ test("renames the branch from a github_pr attachment when no prompt is supplied" deps, ); + expect(created.worktree.branchName).toBe("dazzling-yak"); + + await attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + firstAgentContext: { + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 42, + title: "Investigate flaky checkout test", + url: "https://github.com/acme/repo/pull/42", + }, + ], + }, + generateBranchNameFromContext: async ({ firstAgentContext }) => + firstAgentContext.attachments?.[0]?.type === "github_pr" + ? "renamed-from-pr-attachment" + : null, + }); + const branchAfter = execSync("git branch --show-current", { cwd: created.worktree.worktreePath, stdio: "pipe", @@ -238,9 +260,46 @@ test("renames the branch from a github_pr attachment when no prompt is supplied" .toString() .trim(); - expect(created.worktree.branchName).toBe("renamed-from-pr-attachment"); expect(branchAfter).toBe("renamed-from-pr-attachment"); - expect(created.workspace.displayName).toBe("renamed-from-pr-attachment"); +}); + +test("leaves the branch alone when generated branch text is invalid", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const created = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "dazzling-yak", + firstAgentContext: { prompt: "Name this branch" }, + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + await expect( + attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + firstAgentContext: { prompt: "Name this branch" }, + generateBranchNameFromContext: async () => "Invalid Branch Name", + }), + ).resolves.toEqual({ attempted: true, renamed: false, branchName: null }); + + expect( + execSync("git branch --show-current", { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("dazzling-yak"); + expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + version: 2, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: "dazzling-yak", + }, + }); }); test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => { @@ -271,7 +330,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, firstAgentContext: { prompt: "Rename checkout branch" }, - generateBranchName: () => "must-not-rename", + generateBranchNameFromContext: async () => "must-not-rename", }), ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); expect( @@ -307,7 +366,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, firstAgentContext: { prompt: "Rename PR checkout" }, - generateBranchName: () => "must-not-rename", + generateBranchNameFromContext: async () => "must-not-rename", }), ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); expect( @@ -350,15 +409,13 @@ function createDeps(options?: { events?: string[]; projects?: Map; workspaces?: Map; - generateBranchName?: (seed: string | undefined) => string; }): TestDeps { const events = options?.events ?? []; const projects = options?.projects ?? new Map(); const workspaces = options?.workspaces ?? new Map(); return { - ...createWorktreeCoreDeps(createGitHubServiceStub()), - ...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}), + github: createGitHubServiceStub(), projects, workspaces, projectRegistry: { diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 51ae6a628..667c6c140 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -12,9 +12,8 @@ import { type CreateWorktreeCoreDeps, type CreateWorktreeCoreInput, } from "./worktree-core.js"; -import type { WorktreeConfig } from "../utils/worktree.js"; -import { validateBranchSlug } from "../utils/worktree.js"; -import { renameCurrentBranch } from "../utils/checkout-git.js"; +import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; +import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, readPaseoWorktreeMetadata, @@ -24,7 +23,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "../shared/messages.js"; -export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {} +export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput; export interface CreatePaseoWorktreeResult { worktree: WorktreeConfig; @@ -58,25 +57,18 @@ export async function createPaseoWorktree( deps: CreatePaseoWorktreeDeps, ): Promise { const createdWorktree = await createWorktreeCore(input, deps); - if (!buildAgentBranchNameSeed(input.firstAgentContext)) { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); - } - const worktree = await maybeAutoNameCreatedWorktree({ - input, - createdWorktree, - deps, - }); + maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspace = await upsertWorkspaceForWorktree({ inputCwd: input.cwd, repoRoot: createdWorktree.repoRoot, - worktree, + worktree: createdWorktree.worktree, deps, }); - deps.github.invalidate({ cwd: worktree.worktreePath }); + deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); return { - worktree, + worktree: createdWorktree.worktree, intent: createdWorktree.intent, workspace, repoRoot: createdWorktree.repoRoot, @@ -87,11 +79,15 @@ export async function createPaseoWorktree( export async function attemptFirstAgentBranchAutoName(options: { cwd: string; firstAgentContext: FirstAgentContext | undefined; - generateBranchName: (seed: string | undefined) => string; + generateBranchNameFromContext: (input: { + cwd: string; + firstAgentContext: FirstAgentContext; + }) => Promise; + getCurrentBranch?: typeof getCurrentBranch; renameCurrentBranch?: typeof renameCurrentBranch; }): Promise { - const seed = buildAgentBranchNameSeed(options.firstAgentContext); - if (!seed) { + const firstAgentContext = options.firstAgentContext; + if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) { return { attempted: false, renamed: false, branchName: null }; } @@ -109,11 +105,27 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: false, renamed: false, branchName: null }; } + const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch; + const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName; + if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { + markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + return { attempted: true, renamed: false, branchName: null }; + } + markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); - const branchName = options.generateBranchName(seed); + const branchName = await options.generateBranchNameFromContext({ + cwd: options.cwd, + firstAgentContext, + }); + if (!branchName) { + return { attempted: true, renamed: false, branchName: null }; + } const validation = validateBranchSlug(branchName); - if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) { + if (!validation.valid || branchName === placeholderBranchName) { + return { attempted: true, renamed: false, branchName: null }; + } + if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { return { attempted: true, renamed: false, branchName: null }; } @@ -139,33 +151,6 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: { }); } -async function maybeAutoNameCreatedWorktree(options: { - input: CreatePaseoWorktreeInput; - createdWorktree: Awaited>; - deps: Pick; -}): Promise { - const { input, createdWorktree, deps } = options; - const seed = buildAgentBranchNameSeed(input.firstAgentContext); - if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { - return createdWorktree.worktree; - } - - const branchName = deps.generateBranchName(seed); - const validation = validateBranchSlug(branchName); - if (!validation.valid || branchName === createdWorktree.worktree.branchName) { - return createdWorktree.worktree; - } - - const renamedBranch = await renameCurrentBranch( - createdWorktree.worktree.worktreePath, - branchName, - ); - return { - ...createdWorktree.worktree, - branchName: renamedBranch.currentBranch ?? branchName, - }; -} - async function upsertWorkspaceForWorktree(options: { inputCwd: string; repoRoot: string; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index f903d48b3..ec5b38cd5 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -15,7 +15,6 @@ interface ResolverHarness { github: GitHubService; headRefLookups: GitHubHeadRefLookup[]; resolveDefaultBranch: (repoRoot: string) => Promise; - generateBranchName: (seed: string | undefined) => string; } function createResolverHarness(): ResolverHarness { @@ -51,7 +50,6 @@ function createResolverHarness(): ResolverHarness { github, headRefLookups, resolveDefaultBranch: async () => "main", - generateBranchName: (seed) => seed ?? "generated-worktree", }; } @@ -61,10 +59,12 @@ describe("resolveWorktreeCreationIntent", () => { test("branches off the repo default branch when no explicit fields are set", async () => { const deps = createResolverHarness(); - await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({ + await expect( + resolveWorktreeCreationIntent({ worktreeSlug: "generated-worktree" }, repoRoot, deps), + ).resolves.toEqual({ kind: "branch-off", baseBranch: "main", - newBranchName: "generated-worktree", + branchName: "generated-worktree", }); expect(deps.headRefLookups).toEqual([]); }); @@ -81,7 +81,7 @@ describe("resolveWorktreeCreationIntent", () => { ).resolves.toEqual({ kind: "branch-off", baseBranch: "dev", - newBranchName: "feature", + branchName: "feature", }); expect(deps.headRefLookups).toEqual([]); }); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 1c0973e9e..dbd56838b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -3,17 +3,29 @@ import type { WorktreeSource } from "../utils/worktree.js"; export type WorktreeCreationIntent = WorktreeSource; -export interface ResolveWorktreeCreationIntentInput { - worktreeSlug?: string; - refName?: string; - action?: "branch-off" | "checkout"; - githubPrNumber?: number; -} +export type ResolveWorktreeCreationIntentInput = + | { + worktreeSlug: string; + refName?: string; + action?: "branch-off"; + githubPrNumber?: undefined; + } + | { + worktreeSlug?: string; + refName?: string; + action: "checkout"; + githubPrNumber?: number; + } + | { + worktreeSlug?: string; + refName?: string; + action?: undefined; + githubPrNumber: number; + }; export interface ResolveWorktreeCreationIntentDeps { github: GitHubService; resolveDefaultBranch: (repoRoot: string) => Promise; - generateBranchName: (seed: string | undefined) => string; } export class MissingCheckoutTargetError extends Error { @@ -34,7 +46,7 @@ export async function resolveWorktreeCreationIntent( return { kind: "branch-off", baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)), - newBranchName: deps.generateBranchName(input.worktreeSlug), + branchName: input.worktreeSlug, }; } @@ -72,14 +84,14 @@ export async function resolveWorktreeCreationIntent( return { kind: "branch-off", baseBranch: input.refName.trim(), - newBranchName: deps.generateBranchName(input.worktreeSlug), + branchName: input.worktreeSlug, }; } return { kind: "branch-off", baseBranch: await resolveDefaultBranch(repoRoot, deps), - newBranchName: deps.generateBranchName(input.worktreeSlug), + branchName: input.worktreeSlug, }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 451c9d4bf..79e3ec957 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -207,7 +207,7 @@ import { type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import { createWorktreeCoreDeps } from "./worktree-core.js"; +import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, @@ -3002,7 +3002,7 @@ export class Session { if (!resolvedWorkspace) { throw new Error(`Workspace not found: ${msg.workspaceId}`); } - resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({ + this.scheduleAutoNameWorkspaceBranchForFirstAgent({ workspace: resolvedWorkspace, firstAgentContext, }); @@ -3394,15 +3394,35 @@ export class Session { ); } + private scheduleAutoNameWorkspaceBranchForFirstAgent(input: { + workspace: PersistedWorkspaceRecord; + firstAgentContext: FirstAgentContext; + }): void { + setTimeout(() => { + void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => { + this.sessionLogger.warn( + { err: error, cwd: input.workspace.cwd }, + "Failed to auto-name worktree branch", + ); + }); + }, 0); + } + private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { workspace: PersistedWorkspaceRecord; firstAgentContext: FirstAgentContext; }): Promise { - const coreDeps = createWorktreeCoreDeps(this.github); const result = await attemptFirstAgentBranchAutoName({ cwd: input.workspace.cwd, firstAgentContext: input.firstAgentContext, - generateBranchName: coreDeps.generateBranchName, + generateBranchNameFromContext: ({ cwd, firstAgentContext }) => { + return generateBranchNameFromFirstAgentContext({ + agentManager: this.agentManager, + cwd, + firstAgentContext, + logger: this.sessionLogger, + }); + }, }); if (!result.renamed || !result.branchName) { return input.workspace; @@ -6373,9 +6393,8 @@ export class Session { resolveDefaultBranch?: (repoRoot: string) => Promise; }, ): Promise { - const coreDeps = createWorktreeCoreDeps(this.github); const result = await createPaseoWorktree(input, { - ...coreDeps, + github: this.github, ...(options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {}), @@ -6970,6 +6989,8 @@ export class Session { createPaseoWorktree: (workflowInput, serviceOptions) => this.createPaseoWorktree(workflowInput, serviceOptions), warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), + autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => + this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput), emitWorkspaceUpdateForCwd: (cwd, emitOptions) => this.emitWorkspaceUpdateForCwd(cwd, emitOptions), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 459521f84..5b31cf8fe 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -55,7 +55,7 @@ async function createBootstrapWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: false, paseoHome: options.paseoHome, diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts new file mode 100644 index 000000000..dedd7f09d --- /dev/null +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { AgentManager } from "./agent/agent-manager.js"; +import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; + +function createLogger() { + return { + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("generateBranchNameFromFirstAgentContext", () => { + test("calls the structured generator with first-agent prompt text", async () => { + const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + + const branch = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + firstAgentContext: { prompt: "Fix the login flow" }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + }); + + expect(branch).toBe("fix-login-flow"); + expect(generateStructured).toHaveBeenCalledTimes(1); + expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/repo", + schemaName: "BranchName", + maxRetries: 2, + agentConfigOverrides: { + title: "Branch name generator", + internal: true, + }, + }); + expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow"); + }); + + test("uses attachment-only context", async () => { + const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" })); + + const branch = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + firstAgentContext: { + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 42, + title: "Review flaky checkout", + url: "https://github.com/acme/repo/pull/42", + }, + ], + }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + }); + + expect(branch).toBe("review-flaky-checkout"); + expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout"); + }); +}); diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts new file mode 100644 index 000000000..0c27e2371 --- /dev/null +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import type { FirstAgentContext } from "../shared/messages.js"; +import type { AgentManager } from "./agent/agent-manager.js"; +import { + DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + StructuredAgentFallbackError, + StructuredAgentResponseError, + generateStructuredAgentResponseWithFallback, +} from "./agent/agent-response-loop.js"; +import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; + +interface BranchNameGeneratorLogger { + warn: (obj: object, msg?: string) => void; + error: (obj: object, msg?: string) => void; +} + +export interface GenerateBranchNameFromFirstAgentContextOptions { + agentManager: AgentManager; + cwd: string; + firstAgentContext: FirstAgentContext | undefined; + logger: BranchNameGeneratorLogger; + deps?: { + generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; + }; +} + +const BranchNameSchema = z.object({ + branch: z.string().min(1).max(100), +}); + +function buildPrompt(seed: string): string { + return [ + "Generate a git branch name for a coding agent based on the user prompt and attachments.", + "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", + "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", + "Return JSON only with a single field 'branch'.", + "", + "User context:", + seed, + ].join("\n"); +} + +export async function generateBranchNameFromFirstAgentContext( + options: GenerateBranchNameFromFirstAgentContextOptions, +): Promise { + const seed = buildAgentBranchNameSeed(options.firstAgentContext); + if (!seed) { + return null; + } + + const generator = + options.deps?.generateStructuredAgentResponseWithFallback ?? + generateStructuredAgentResponseWithFallback; + + try { + const result = await generator({ + manager: options.agentManager, + cwd: options.cwd, + prompt: buildPrompt(seed), + schema: BranchNameSchema, + schemaName: "BranchName", + maxRetries: 2, + providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + agentConfigOverrides: { + title: "Branch name generator", + internal: true, + }, + }); + return result.branch.trim() || null; + } catch (error) { + if ( + error instanceof StructuredAgentResponseError || + error instanceof StructuredAgentFallbackError + ) { + options.logger.warn({ err: error }, "Structured branch name generation failed"); + return null; + } + options.logger.error({ err: error }, "Branch name generation failed"); + return null; + } +} diff --git a/packages/server/src/server/worktree-core.test.ts b/packages/server/src/server/worktree-core.test.ts index 6a3e6dcc2..648713e20 100644 --- a/packages/server/src/server/worktree-core.test.ts +++ b/packages/server/src/server/worktree-core.test.ts @@ -44,17 +44,13 @@ function createGitHubServiceStub(): GitHubService { }; } -function createCoreDeps(options?: { - github?: GitHubService; - generateBranchName?: (seed: string | undefined) => string; -}) { +function createCoreDeps(options?: { github?: GitHubService }) { return { github: options?.github ?? createGitHubServiceStub(), workspaceGitService: { resolveRepoRoot: async (cwd: string) => cwd, }, resolveDefaultBranch: async () => "main", - generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"), }; } @@ -191,13 +187,33 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { expect(result.intent).toEqual({ kind: "branch-off", baseBranch: "main", - newBranchName: "legacy-rpc", + branchName: "legacy-rpc", }); expect(result.created).toBe(true); expect(result.worktree.branchName).toBe("legacy-rpc"); expect(existsSync(result.worktree.worktreePath)).toBe(true); }); + test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent.kind).toBe("branch-off"); + expect(result.created).toBe(true); + expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath)); + expect(existsSync(result.worktree.worktreePath)).toBe(true); + }); + test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => { const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); cleanupPaths.push(tempDir); @@ -259,7 +275,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { expect(result.intent).toEqual({ kind: "branch-off", baseBranch: "main", - newBranchName: "mcp-standalone", + branchName: "mcp-standalone", }); expect(result.worktree.branchName).toBe("mcp-standalone"); }); @@ -290,7 +306,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { expect(result.intent).toEqual({ kind: "branch-off", baseBranch: "dev", - newBranchName: "from-dev", + branchName: "from-dev", }); expect(mergeBase).toBe(devTip); }); @@ -500,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { expect(result.intent).toEqual({ kind: "branch-off", baseBranch: "main", - newBranchName: "agent-worktree", + branchName: "agent-worktree", }); expect(result.worktree.branchName).toBe("agent-worktree"); }); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 67da55575..d61130f8e 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -1,4 +1,4 @@ -import { v4 as uuidv4 } from "uuid"; +import { createNameId } from "mnemonic-id"; import type { GitHubService } from "../services/github-service.js"; import { @@ -16,8 +16,12 @@ import { import type { FirstAgentContext } from "../shared/messages.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { +export interface CreateWorktreeCoreInput { cwd: string; + worktreeSlug?: string; + refName?: string; + action?: "branch-off" | "checkout"; + githubPrNumber?: number; firstAgentContext?: FirstAgentContext; paseoHome?: string; runSetup?: boolean; @@ -27,7 +31,6 @@ export interface CreateWorktreeCoreDeps { github: GitHubService; workspaceGitService?: Pick; resolveDefaultBranch?: (repoRoot: string) => Promise; - generateBranchName: (seed: string | undefined) => string; } export interface CreateWorktreeCoreResult { @@ -42,31 +45,51 @@ export async function createWorktreeCore( deps: CreateWorktreeCoreDeps, ): Promise { const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); - const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined; + const requestedWorktreeSlug = input.worktreeSlug + ? normalizeWorktreeSlug(input.worktreeSlug) + : undefined; - const intent = await resolveWorktreeCreationIntent( - { ...input, worktreeSlug: requestedSlug }, - repoRoot, - { - ...deps, - resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps), - }, - ); + let intentInput: ResolveWorktreeCreationIntentInput; + if (input.action === "checkout") { + intentInput = { + action: "checkout", + refName: input.refName, + githubPrNumber: input.githubPrNumber, + worktreeSlug: requestedWorktreeSlug, + }; + } else if (input.githubPrNumber !== undefined) { + intentInput = { + githubPrNumber: input.githubPrNumber, + refName: input.refName, + worktreeSlug: requestedWorktreeSlug, + }; + } else { + const worktreeSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(createNameId()); + intentInput = { + action: "branch-off", + refName: input.refName, + worktreeSlug, + }; + } + + const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, { + ...deps, + resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps), + }); let normalizedSlug: string; switch (intent.kind) { case "branch-off": { - normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName)); + normalizedSlug = intent.branchName; break; } case "checkout-branch": { - normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName)); + normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName); break; } case "checkout-github-pr": { - normalizedSlug = validateWorktreeSlug( - requestedSlug ?? slugify(intent.localBranchName ?? intent.headRef), - ); + normalizedSlug = + requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef); break; } } @@ -94,13 +117,6 @@ export async function createWorktreeCore( }; } -export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps { - return { - github, - generateBranchName: (seed) => slugify(seed ?? uuidv4()), - }; -} - async function resolveDefaultBranch( repoRoot: string, deps: CreateWorktreeCoreDeps, @@ -132,3 +148,7 @@ function validateWorktreeSlug(slug: string): string { } return slug; } + +function normalizeWorktreeSlug(value: string): string { + return validateWorktreeSlug(slugify(value)); +} diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 325b2ee9d..621a017ad 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -39,7 +39,6 @@ import { createPaseoWorktree as createPaseoWorktreeService, type CreatePaseoWorktreeFn, } from "./paseo-worktree-service.js"; -import { createWorktreeCoreDeps } from "./worktree-core.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -65,7 +64,7 @@ function createLegacyWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, @@ -262,9 +261,8 @@ function createPaseoWorktreeForTest(options: { }); return (input, serviceOptions) => { - const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub()); return createPaseoWorktreeService(input, { - ...coreDeps, + github: createGitHubServiceStub(), ...(serviceOptions?.resolveDefaultBranch ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } : {}), @@ -1303,7 +1301,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { intent: { kind: "branch-off" as const, baseBranch: "main", - newBranchName: "fix-attached-pr-context", + branchName: "fix-attached-pr-context", }, workspace: { workspaceId: "/tmp/worktrees/fix-attached-pr-context", @@ -1445,7 +1443,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(result.intent).toMatchObject({ kind: "branch-off", baseBranch: "main", - newBranchName: "resolver-feature", + branchName: "resolver-feature", }); expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir); }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 5a28c1d76..bb6af62c7 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -115,6 +115,10 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB }, ) => Promise; warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise; + autoNameWorkspaceBranchForFirstAgent?: (input: { + workspace: PersistedWorkspaceRecord; + firstAgentContext: FirstAgentContext; + }) => void; } interface AgentWorktreeSetupContinuationInput { @@ -576,6 +580,12 @@ export async function createPaseoWorktreeWorkflow( const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; setTimeout(() => { + if (input.firstAgentContext) { + dependencies.autoNameWorkspaceBranchForFirstAgent?.({ + workspace, + firstAgentContext: input.firstAgentContext, + }); + } void dependencies.warmWorkspaceGitData(workspace).catch((error) => { dependencies.sessionLogger.warn( { err: error, workspaceId: workspace.workspaceId }, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index db896c913..ba471de07 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -64,7 +64,7 @@ function createLegacyWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index dfddac5a5..a04eb562c 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -62,7 +62,7 @@ function createLegacyWorktreeForTest( source: { kind: "branch-off", baseBranch: options.baseBranch, - newBranchName: options.branchName, + branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, @@ -182,7 +182,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, worktreeSlug: "my-feature", - source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" }, + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" }, runSetup: true, paseoHome, }); @@ -796,7 +796,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, worktreeSlug: "seed-uncommitted", - source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/seed" }, + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" }, runSetup: false, paseoHome, }); @@ -824,7 +824,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, worktreeSlug: "preserve-committed", - source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/preserve" }, + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" }, runSetup: false, paseoHome, }); @@ -839,7 +839,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, worktreeSlug: "no-config", - source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/no-config" }, + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" }, runSetup: false, paseoHome, }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 88ae897ca..cec80ae67 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -150,7 +150,7 @@ export interface PaseoWorktreeOwnership { } export type WorktreeSource = - | { kind: "branch-off"; baseBranch: string; newBranchName: string } + | { kind: "branch-off"; baseBranch: string; branchName: string } | { kind: "checkout-branch"; branchName: string } | { kind: "checkout-github-pr"; @@ -1292,7 +1292,7 @@ async function resolveWorktreeSourcePlan({ }: ResolveWorktreeSourcePlanOptions): Promise { switch (source.kind) { case "branch-off": { - const branchName = source.newBranchName; + const branchName = source.branchName; validateWorktreeBranchName(branchName); const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);