From c2ad4a02efee0ed3ee80498c878213b751c19ad8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Apr 2026 21:37:17 +0800 Subject: [PATCH] refactor: unify worktree creation workflow (#636) Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only. --- packages/server/src/client/daemon-client.ts | 3 +- .../server/agent/agent-metadata-generator.ts | 134 +----- .../agent-metadata-generator.unit.test.ts | 155 +------ .../src/server/agent/mcp-server.test.ts | 390 +++++++++++++++++- .../server/src/server/agent/mcp-server.ts | 137 +++--- packages/server/src/server/bootstrap.ts | 67 ++- .../src/server/paseo-worktree-service.test.ts | 199 +++++++-- .../src/server/paseo-worktree-service.ts | 129 +++++- packages/server/src/server/session.ts | 125 ++++-- .../server/src/server/worktree-core.test.ts | 43 -- packages/server/src/server/worktree-core.ts | 1 + .../src/server/worktree-session.test.ts | 255 ++++++++++-- .../server/src/server/worktree-session.ts | 185 +++++++-- packages/server/src/shared/messages.ts | 1 + .../server/src/utils/worktree-metadata.ts | 73 ++++ 15 files changed, 1328 insertions(+), 569 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8c748f1dc..d7c2f52f6 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -243,7 +243,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, - "cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber" + "cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber" > {} type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -2757,6 +2757,7 @@ export class DaemonClient { type: "create_paseo_worktree_request", cwd: input.cwd, worktreeSlug: input.worktreeSlug, + ...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}), ...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}), diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 158cf3b85..0b388a110 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -1,4 +1,3 @@ -import { basename } from "path"; import { z } from "zod"; import type { Logger } from "pino"; @@ -9,15 +8,10 @@ import { StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; -import { validateBranchSlug } from "../../utils/worktree.js"; -import { renameCurrentBranch } from "../../utils/checkout-git.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js"; export interface AgentMetadataGeneratorDeps { generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; - renameCurrentBranch?: typeof renameCurrentBranch; - workspaceGitService?: Pick; } export interface AgentMetadataGenerationOptions { @@ -34,7 +28,6 @@ export interface AgentMetadataGenerationOptions { interface AgentMetadataNeeds { prompt: string | null; needsTitle: boolean; - needsBranch: boolean; } function hasExplicitTitle(title?: string | null): boolean { @@ -49,33 +42,6 @@ function normalizeAutoTitle(title: string): string | null { return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null; } -async function canRenameBranch( - cwd: string, - workspaceGitService: Pick | undefined, -): Promise { - if (!workspaceGitService) { - return false; - } - - let snapshot: WorkspaceGitRuntimeSnapshot; - try { - snapshot = await workspaceGitService.getSnapshot(cwd); - } catch { - return false; - } - - if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) { - return false; - } - - if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) { - return false; - } - - const worktreeDirName = basename(snapshot.git.repoRoot); - return snapshot.git.currentBranch === worktreeDirName; -} - export async function determineAgentMetadataNeeds( options: Pick< AgentMetadataGenerationOptions, @@ -84,23 +50,21 @@ export async function determineAgentMetadataNeeds( ): Promise { const prompt = options.initialPrompt?.trim(); if (!prompt) { - return { prompt: null, needsTitle: false, needsBranch: false }; + return { prompt: null, needsTitle: false }; } const needsTitle = !hasExplicitTitle(options.explicitTitle); - const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService); return { prompt, needsTitle, - needsBranch, }; } function buildMetadataSchema( needs: AgentMetadataNeeds, ): z.ZodObject> | null { - if (!needs.needsTitle && !needs.needsBranch) { + if (!needs.needsTitle) { return null; } @@ -108,33 +72,16 @@ function buildMetadataSchema( if (needs.needsTitle) { shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS); } - if (needs.needsBranch) { - shape.branch = z.string().min(1).max(100); - } return z.object(shape); } function buildPrompt(needs: AgentMetadataNeeds): string { - const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter( - Boolean, - ) as string[]; - const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."]; if (needs.needsTitle) { instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`); } - if (needs.needsBranch) { - instructions.push( - "Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.", - ); - } - - if (fields.length === 1) { - instructions.push(`Return JSON only with a single field '${fields[0]}'.`); - } else { - instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`); - } + instructions.push("Return JSON only with a single field 'title'."); instructions.push("", "User prompt:", needs.prompt ?? ""); return instructions.join("\n"); @@ -156,9 +103,8 @@ export async function generateAndApplyAgentMetadata( const generator = options.deps?.generateStructuredAgentResponseWithFallback ?? generateStructuredAgentResponseWithFallback; - const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch; - let result: { title?: string; branch?: string }; + let result: { title?: string }; try { result = await generator({ @@ -198,78 +144,6 @@ export async function generateAndApplyAgentMetadata( await options.agentManager.setTitle(options.agentId, normalizedTitle); } } - - if (needs.needsBranch && typeof result.branch === "string") { - await applyGeneratedBranchRename({ - options, - branch: result.branch, - renameCurrentBranchImpl, - }); - } -} - -async function applyGeneratedBranchRename(params: { - options: AgentMetadataGenerationOptions; - branch: string; - renameCurrentBranchImpl: typeof renameCurrentBranch; -}): Promise { - const { options, branch, renameCurrentBranchImpl } = params; - const normalizedBranch = branch.trim(); - const validation = validateBranchSlug(normalizedBranch); - if (!validation.valid) { - options.logger.warn( - { agentId: options.agentId, branch: normalizedBranch, error: validation.error }, - "Generated branch name is invalid", - ); - return; - } - - const workspaceGitService = options.deps?.workspaceGitService; - if (!workspaceGitService) { - return; - } - - let snapshot: WorkspaceGitRuntimeSnapshot; - try { - snapshot = await workspaceGitService.getSnapshot(options.cwd); - } catch (error) { - options.logger.warn( - { err: error, agentId: options.agentId }, - "Failed to re-check branch eligibility", - ); - return; - } - - if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) { - return; - } - - const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null; - if (snapshot.git.currentBranch !== worktreeDirName) { - return; - } - - try { - await renameCurrentBranchImpl(options.cwd, normalizedBranch); - try { - await workspaceGitService.getSnapshot(options.cwd, { - force: true, - reason: "rename-branch", - }); - } catch (error) { - options.logger.warn( - { err: error, agentId: options.agentId, cwd: options.cwd }, - "Failed to force-refresh workspace git snapshot after branch rename", - ); - } - options.agentManager.notifyAgentState(options.agentId); - await options.agentManager.flush(); - } catch (error) { - options.logger.warn( - { err: error, agentId: options.agentId, branch: normalizedBranch }, - "Failed to rename branch", - ); - } } export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void { diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 35998690b..40a222af2 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -7,34 +7,9 @@ import { type AgentMetadataGeneratorDeps, } from "./agent-metadata-generator.js"; import type { AgentManager } from "./agent-manager.js"; -import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; const logger = createTestLogger(); -const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = { - cwd: "/tmp/repo/metadata-worktree", - git: { - isGit: true, - repoRoot: "/tmp/repo/metadata-worktree", - mainRepoRoot: "/tmp/repo", - currentBranch: "metadata-worktree", - remoteUrl: null, - isPaseoOwnedWorktree: true, - isDirty: false, - baseRef: "main", - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - hasRemote: false, - diffStat: null, - }, - github: { - featuresEnabled: false, - pullRequest: null, - error: null, - }, -}; - function createDeps( generateStructuredAgentResponseWithFallback: NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] @@ -89,134 +64,30 @@ describe("agent metadata generator auto-title", () => { expect(setTitle).not.toHaveBeenCalled(); }); - it("notifies agent state after successfully renaming a generated branch", async () => { + it("generates titles independently from workspace branch naming", async () => { const setTitle = vi.fn().mockResolvedValue(undefined); - const notifyAgentState = vi.fn(); - const flush = vi.fn().mockResolvedValue(undefined); - const manager = { - setTitle, - notifyAgentState, - flush, - } as unknown as AgentManager; - const renameCurrentBranch = vi.fn().mockResolvedValue({ - previousBranch: "metadata-worktree", - currentBranch: "feature/metadata-worktree", - }) as NonNullable; - const generateStructured = vi.fn().mockResolvedValue({ - branch: "feature/metadata-worktree", - }) as NonNullable; - const workspaceGitService = { - getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), - }; + const manager = { setTitle } as unknown as AgentManager; + const generateStructured = vi + .fn() + .mockResolvedValue({ title: "Generated title" }) as NonNullable< + AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] + >; await generateAndApplyAgentMetadata({ agentManager: manager, - agentId: "agent-branch", + agentId: "agent-suppressed-branch", cwd: "/tmp/repo/metadata-worktree", - initialPrompt: "Rename this worktree branch.", - explicitTitle: "Keep explicit title", - paseoHome: "/tmp/paseo-home", + initialPrompt: "Implement this feature", + explicitTitle: null, logger, deps: { generateStructuredAgentResponseWithFallback: generateStructured, - renameCurrentBranch, - workspaceGitService, }, }); - expect(renameCurrentBranch).toHaveBeenCalledWith( - "/tmp/repo/metadata-worktree", - "feature/metadata-worktree", - ); - expect(notifyAgentState).toHaveBeenCalledWith("agent-branch"); - expect(setTitle).not.toHaveBeenCalled(); - }); - - it("forces a workspace git snapshot refresh after renaming a generated branch", async () => { - const manager = { - setTitle: vi.fn().mockResolvedValue(undefined), - notifyAgentState: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - } as unknown as AgentManager; - const renameCurrentBranch = vi.fn().mockResolvedValue({ - previousBranch: "metadata-worktree", - currentBranch: "feature/metadata-worktree", - }) as NonNullable; - const generateStructured = vi.fn().mockResolvedValue({ - branch: "feature/metadata-worktree", - }) as NonNullable; - const workspaceGitService = { - getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), - }; - - await generateAndApplyAgentMetadata({ - agentManager: manager, - agentId: "agent-branch-refresh", - cwd: "/tmp/repo/metadata-worktree", - initialPrompt: "Rename this worktree branch.", - explicitTitle: "Keep explicit title", - logger, - deps: { - generateStructuredAgentResponseWithFallback: generateStructured, - renameCurrentBranch, - workspaceGitService: workspaceGitService as unknown as Pick< - import("../workspace-git-service.js").WorkspaceGitService, - "getSnapshot" - >, - }, - }); - - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", { - force: true, - reason: "rename-branch", - }); - }); - - it("uses the workspace git service snapshot for branch rename eligibility checks", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const notifyAgentState = vi.fn(); - const manager = { - setTitle, - notifyAgentState, - flush: vi.fn().mockResolvedValue(undefined), - } as unknown as AgentManager; - const workspaceGitService = { - getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), - }; - const renameCurrentBranch = vi.fn().mockResolvedValue({ - previousBranch: "metadata-worktree", - currentBranch: "feature/metadata-worktree", - }) as NonNullable; - const generateStructured = vi.fn().mockResolvedValue({ - branch: "feature/metadata-worktree", - }) as NonNullable; - - await generateAndApplyAgentMetadata({ - agentManager: manager, - agentId: "agent-service-branch", - cwd: "/tmp/repo/metadata-worktree", - initialPrompt: "Rename this worktree branch.", - explicitTitle: "Keep explicit title", - logger, - deps: { - generateStructuredAgentResponseWithFallback: generateStructured, - renameCurrentBranch, - workspaceGitService: workspaceGitService as unknown as Pick< - import("../workspace-git-service.js").WorkspaceGitService, - "getSnapshot" - >, - }, - }); - - expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree"); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", { - force: true, - reason: "rename-branch", - }); - expect(renameCurrentBranch).toHaveBeenCalledWith( - "/tmp/repo/metadata-worktree", - "feature/metadata-worktree", + expect(generateStructured).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }), ); + expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 6c59761f9..3ccd5208a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -18,8 +18,9 @@ import type { AgentProvider } from "./agent-sdk-types.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import { createPaseoWorktree as createPaseoWorktreeService, - type CreatePaseoWorktreeFn, + 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"; @@ -292,7 +293,9 @@ function createPaseoWorktreeForMcpTest(options: { paseoHome: string; broadcasts: string[]; createdWorkspaceIds?: string[]; -}): CreatePaseoWorktreeFn { + setupContinuations?: Array<"workspace" | "agent" | undefined>; + startedAgentSetupIds?: string[]; +}): CreatePaseoWorktreeWorkflowFn { const projects = new Map(); const workspaces = new Map(); const github = createGitHubServiceStub(); @@ -303,6 +306,7 @@ function createPaseoWorktreeForMcpTest(options: { }); return async (input, serviceOptions) => { + options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); const coreDeps = createWorktreeCoreDeps(github); const result = await createPaseoWorktreeService(input, { ...coreDeps, @@ -326,6 +330,17 @@ function createPaseoWorktreeForMcpTest(options: { }); options.broadcasts.push(result.workspace.workspaceId); options.createdWorkspaceIds?.push(result.workspace.workspaceId); + if (serviceOptions?.setupContinuation?.kind === "agent") { + return { + ...result, + setupContinuation: { + kind: "agent", + startAfterAgentCreate: ({ agentId }) => { + options.startedAgentSetupIds?.push(agentId); + }, + }, + }; + } return result; }; } @@ -478,13 +493,26 @@ describe("create_agent MCP tool", () => { expect(parsed.success).toBe(true); }); + it("accepts optional name context in create_worktree input validation", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "create_worktree"); + + const parsed = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + nameContext: "Fix workspace creation naming", + }); + + expect(parsed.success).toBe(true); + }); + it("rejects create_worktree without a branch name or checkout intent", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = registeredTool(server, "create_worktree"); await expect(tool.callback({})).rejects.toThrow( - "create_worktree requires branchName, refName, or githubPrNumber", + "create_worktree requires branchName, nameContext, refName, or githubPrNumber", ); }); @@ -608,6 +636,8 @@ describe("create_agent MCP tool", () => { const paseoHome = join(tempDir, ".paseo"); const broadcasts: string[] = []; const createdWorkspaceIds: string[] = []; + const setupContinuations: Array<"workspace" | "agent" | undefined> = []; + const startedAgentSetupIds: string[] = []; try { execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); @@ -636,6 +666,8 @@ describe("create_agent MCP tool", () => { paseoHome, broadcasts, createdWorkspaceIds, + setupContinuations, + startedAgentSetupIds, }), logger, }); @@ -653,6 +685,8 @@ describe("create_agent MCP tool", () => { expect(broadcasts).toHaveLength(1); expect(createdWorkspaceIds).toHaveLength(1); expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); + expect(setupContinuations).toEqual(["agent"]); + expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: expect.stringContaining("agent-worktree"), @@ -665,12 +699,243 @@ describe("create_agent MCP tool", () => { } }); + it("auto-names a create_agent branch-off worktree from the initial prompt without metadata branch rename", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(async () => { + throw new Error("agent metadata branch rename should not run"); + }), + }; + + try { + execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); + execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-auto-named-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Worktree agent" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + workspaceGitService: workspaceGitService as unknown as Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" + >, + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.callback({ + cwd: repoDir, + title: "Worktree agent", + provider: "codex/gpt-5.4", + initialPrompt: "Fix workspace creation naming", + action: "branch-off", + baseBranch: "main", + background: true, + }); + + const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string; + expect( + execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(), + ).toBe("fix-workspace-creation-naming"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(broadcasts).toHaveLength(1); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(async () => { + throw new Error("agent metadata branch rename should not run"); + }), + }; + + try { + execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); + execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" }); + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-checkout-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Checkout agent" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + workspaceGitService: workspaceGitService as unknown as Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" + >, + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.callback({ + cwd: repoDir, + title: "Checkout agent", + provider: "codex/gpt-5.4", + initialPrompt: "Rename this checkout from the prompt", + action: "checkout", + refName: "existing-feature", + background: true, + }); + + const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string; + expect( + execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(), + ).toBe("existing-feature"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(broadcasts).toHaveLength(1); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const startedAgentSetupIds: string[] = []; + const createPaseoWorktree = vi.fn( + async ( + input: CreatePaseoWorktreeInput, + options?: Parameters[1], + ) => ({ + worktree: { + branchName: "pr-123", + worktreePath: "/tmp/worktrees/pr-123", + }, + intent: { + kind: "checkout-github-pr" as const, + githubPrNumber: input.githubPrNumber ?? 123, + headRef: "pr-123", + baseRefName: "main", + }, + workspace: { + workspaceId: "/tmp/worktrees/pr-123", + projectId: "/tmp/repo", + cwd: "/tmp/worktrees/pr-123", + kind: "worktree" as const, + displayName: "pr-123", + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + archivedAt: null, + }, + repoRoot: "/tmp/repo", + created: true, + ...(options?.setupContinuation?.kind === "agent" + ? { + setupContinuation: { + kind: "agent" as const, + startAfterAgentCreate: ({ agentId }: { agentId: string }) => { + startedAgentSetupIds.push(agentId); + }, + }, + } + : {}), + }), + ); + const workspaceGitService = { + getSnapshot: vi.fn(async () => { + throw new Error("agent metadata branch rename should not run"); + }), + }; + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-pr-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "PR agent" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + createPaseoWorktree, + workspaceGitService: workspaceGitService as unknown as Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" + >, + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.callback({ + cwd: "/tmp/repo", + title: "PR agent", + provider: "codex/gpt-5.4", + initialPrompt: "Rename this PR branch from prompt", + githubPrNumber: 123, + background: true, + }); + + expect(createPaseoWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + githubPrNumber: 123, + nameContext: "Rename this PR branch from prompt", + }), + expect.objectContaining({ + setupContinuation: expect.objectContaining({ kind: "agent" }), + }), + ); + expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]); + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }), + undefined, + undefined, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + }); + it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-")); const repoDir = join(tempDir, "repo"); const paseoHome = join(tempDir, ".paseo"); const broadcasts: string[] = []; + const setupContinuations: Array<"workspace" | "agent" | undefined> = []; try { execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); @@ -689,7 +954,11 @@ describe("create_agent MCP tool", () => { agentManager, agentStorage, paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + createPaseoWorktree: createPaseoWorktreeForMcpTest({ + paseoHome, + broadcasts, + setupContinuations, + }), workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, "getSnapshot" | "listWorktrees" @@ -705,17 +974,8 @@ describe("create_agent MCP tool", () => { expect(response.structuredContent.branchName).toBe("tool-worktree"); expect(response.structuredContent.worktreePath).toContain("tool-worktree"); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "create-worktree", - }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith( - response.structuredContent.worktreePath, - { - force: true, - reason: "create-worktree", - }, - ); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(setupContinuations).toEqual([undefined]); expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toContain("tool-worktree"); } finally { @@ -723,6 +983,106 @@ describe("create_agent MCP tool", () => { } }); + it("auto-names a standalone branch-off worktree from create_worktree name context", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-name-context-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + + try { + execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); + execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + logger, + }); + const tool = registeredTool(server, "create_worktree"); + const response = await tool.callback({ + cwd: repoDir, + nameContext: "Fix workspace creation naming", + baseBranch: "main", + }); + + expect(response.structuredContent.branchName).toBe("fix-workspace-creation-naming"); + expect( + execSync("git branch --show-current", { + cwd: response.structuredContent.worktreePath as string, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("fix-workspace-creation-naming"); + expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); + expect(broadcasts).toHaveLength(1); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not rename checkout-created worktrees from create_worktree name context", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-checkout-worktree-name-context-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + + try { + execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); + execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" }); + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + logger, + }); + const tool = registeredTool(server, "create_worktree"); + const response = await tool.callback({ + cwd: repoDir, + action: "checkout", + refName: "existing-feature", + nameContext: "Should Not Rename Checkout", + }); + + expect(response.structuredContent.branchName).toBe("existing-feature"); + expect( + execSync("git branch --show-current", { + cwd: response.structuredContent.worktreePath as string, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("existing-feature"); + expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); + expect(broadcasts).toHaveLength(1); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index fd6020700..79393cfbf 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -27,7 +27,7 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./timeline-append.js"; -import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js"; +import { getPaseoWorktreesRoot } from "../../utils/worktree.js"; import { archivePaseoWorktree, killTerminalsUnderPath, @@ -39,7 +39,12 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { captureTerminalLines } from "../../terminal/terminal.js"; -import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; +import type { + AgentWorktreeSetupContinuation, + CreatePaseoWorktreeSetupContinuationInput, + CreatePaseoWorktreeWorkflowFn, + CreatePaseoWorktreeWorkflowResult, +} from "../worktree-session.js"; import type { ScheduleService } from "../schedule/service.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; @@ -61,11 +66,7 @@ import { } from "./mcp-shared.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { - CreatePaseoWorktreeFn, - CreatePaseoWorktreeInput, - CreatePaseoWorktreeResult, -} from "../paseo-worktree-service.js"; +import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; import { toWorktreeRequestError } from "../worktree-errors.js"; import { join } from "node:path"; @@ -83,7 +84,7 @@ export interface AgentMcpServerOptions { markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"]; - createPaseoWorktree?: CreatePaseoWorktreeFn; + createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. @@ -625,8 +626,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom notifyOnFinish: boolean; resolvedCwd: string; resolvedMode: string | undefined; - worktreeConfig: WorktreeConfig | undefined; - shouldBootstrapWorktree: boolean | undefined; + setupContinuation: AgentWorktreeSetupContinuation | undefined; } const resolveCallerCreateAgentArgs = ( @@ -661,8 +661,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom notifyOnFinish: callerArgs.notifyOnFinish ?? false, resolvedCwd, resolvedMode, - worktreeConfig: undefined, - shouldBootstrapWorktree: undefined, + setupContinuation: undefined, }; }; @@ -673,11 +672,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; let resolvedCwd = expandUserPath(cwd); - let worktreeConfig: WorktreeConfig | undefined; - let shouldBootstrapWorktree: boolean | undefined; + let setupContinuation: AgentWorktreeSetupContinuation | undefined; - if (worktreeName) { - if (!baseBranch && !refName && !action && githubPrNumber === undefined) { + const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber); + if (shouldCreateWorktree) { + if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) { throw new Error("baseBranch is required when creating a worktree"); } const createdWorktree = await createMcpWorktree({ @@ -687,17 +686,32 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom refName, action, githubPrNumber, + nameContext: topLevelArgs.initialPrompt, runSetup: false, paseoHome: options.paseoHome, }, createPaseoWorktree: options.createPaseoWorktree, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - workspaceGitService: options.workspaceGitService, - logger: options.logger, + setupContinuation: { + kind: "agent", + terminalManager: terminalManager ?? null, + appendTimelineItem: ({ agentId, item }) => + appendTimelineItemIfAgentKnown({ + agentManager, + agentId, + item, + }), + emitLiveTimelineItem: ({ agentId, item }) => + emitLiveTimelineItemIfAgentKnown({ + agentManager, + agentId, + item, + }), + logger: childLogger, + }, }); resolvedCwd = createdWorktree.worktree.worktreePath; - worktreeConfig = createdWorktree.worktree; - shouldBootstrapWorktree = createdWorktree.created; + setupContinuation = createdWorktree.setupContinuation; } return { @@ -711,8 +725,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, resolvedMode: mode, - worktreeConfig, - shouldBootstrapWorktree, + setupContinuation, }; }; @@ -755,8 +768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom notifyOnFinish, resolvedCwd, resolvedMode, - worktreeConfig, - shouldBootstrapWorktree, + setupContinuation, } = resolved; const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; @@ -778,27 +790,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined, ); - if (worktreeConfig) { - void runAsyncWorktreeBootstrap({ - agentId: snapshot.id, - worktree: worktreeConfig, - shouldBootstrap: shouldBootstrapWorktree, - terminalManager: terminalManager ?? null, - appendTimelineItem: (item) => - appendTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - emitLiveTimelineItem: (item) => - emitLiveTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - logger: childLogger, - }); - } + setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); const trimmedPrompt = initialPrompt.trim(); scheduleAgentMetadataGeneration({ @@ -809,11 +803,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom explicitTitle: snapshot.config.title, paseoHome: options.paseoHome, logger: childLogger, - deps: options.workspaceGitService - ? { - workspaceGitService: options.workspaceGitService, - } - : undefined, }); try { @@ -1779,6 +1768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .optional() .describe("Optional repository cwd. Defaults to the caller agent cwd."), branchName: z.string().optional(), + nameContext: z.string().optional(), baseBranch: z.string().optional(), refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), @@ -1789,15 +1779,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom worktreePath: z.string(), }, }, - async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => { - if (!branchName && !refName && githubPrNumber === undefined) { - throw new Error("create_worktree requires branchName, refName, or githubPrNumber"); + async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => { + if (!branchName && !nameContext && !refName && githubPrNumber === undefined) { + throw new Error( + "create_worktree requires branchName, nameContext, refName, or githubPrNumber", + ); } const repoRoot = resolveScopedCwd(cwd, { required: true }); const createdWorktree = await createMcpWorktree({ input: { cwd: repoRoot, worktreeSlug: branchName, + nameContext, refName, action, githubPrNumber, @@ -1806,8 +1799,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, createPaseoWorktree: options.createPaseoWorktree, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - workspaceGitService: options.workspaceGitService, - logger: options.logger, }); const { worktree } = createdWorktree; @@ -2050,46 +2041,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom interface CreateMcpWorktreeOptions { input: CreatePaseoWorktreeInput; - createPaseoWorktree: CreatePaseoWorktreeFn | undefined; + createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; resolveDefaultBranch?: (repoRoot: string) => Promise; - workspaceGitService?: Pick; - logger: Logger; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; } async function createMcpWorktree( options: CreateMcpWorktreeOptions, -): Promise { +): Promise { try { if (!options.createPaseoWorktree) { throw new Error("Paseo worktree service is not configured"); } const result = await options.createPaseoWorktree(options.input, { - resolveDefaultBranch: options.resolveDefaultBranch, + ...(options.resolveDefaultBranch + ? { resolveDefaultBranch: options.resolveDefaultBranch } + : {}), + ...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}), }); - if (options.workspaceGitService) { - const refreshResults = await Promise.allSettled([ - options.workspaceGitService.getSnapshot(options.input.cwd, { - force: true, - reason: "create-worktree", - }), - options.workspaceGitService.getSnapshot(result.worktree.worktreePath, { - force: true, - reason: "create-worktree", - }), - ]); - for (const [index, refreshResult] of refreshResults.entries()) { - if (refreshResult.status === "fulfilled") { - continue; - } - options.logger.warn( - { - err: refreshResult.reason, - cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath, - }, - "Failed to force-refresh workspace git snapshot after creating worktree", - ); - } - } return result; } catch (error) { throw toWorktreeRequestError(error); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 03b91bf59..1827745cf 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -88,7 +88,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null { import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { createGitHubService } from "../services/github-service.js"; -import { createPaseoWorktree } from "./paseo-worktree-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"; @@ -562,24 +563,54 @@ export async function createPaseoDaemon( clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive, emitSessionMessage: emitMcpArchiveSessionMessage, createPaseoWorktree: async (input, serviceOptions) => { - const coreDeps = createWorktreeCoreDeps(github); - const result = await createPaseoWorktree(input, { - ...coreDeps, - ...(serviceOptions?.resolveDefaultBranch - ? { - resolveDefaultBranch: serviceOptions.resolveDefaultBranch, - } - : {}), - projectRegistry, - workspaceRegistry, - workspaceGitService, - }); - await Promise.all( - wsServer - ?.listActiveSessions() - .map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [], + return createPaseoWorktreeWorkflow( + { + paseoHome: config.paseoHome, + createPaseoWorktree: async (workflowInput, workflowOptions) => { + const coreDeps = createWorktreeCoreDeps(github); + return createRegisteredPaseoWorktree(workflowInput, { + ...coreDeps, + ...(workflowOptions?.resolveDefaultBranch + ? { + resolveDefaultBranch: workflowOptions.resolveDefaultBranch, + } + : {}), + projectRegistry, + workspaceRegistry, + workspaceGitService, + }); + }, + warmWorkspaceGitData: async (workspace) => { + await Promise.all( + wsServer + ?.listActiveSessions() + .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], + ); + }, + emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => { + await Promise.all( + wsServer + ?.listActiveSessions() + .map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [], + ); + void emitOptions; + }, + cacheWorkspaceSetupSnapshot: () => {}, + emit: emitMcpArchiveSessionMessage, + sessionLogger: logger, + terminalManager, + archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, + scriptRouteStore, + scriptRuntimeStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, + getDaemonTcpHost: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.host : null, + onScriptsChanged: null, + }, + input, + serviceOptions, ); - return result; }, paseoHome: config.paseoHome, callerAgentId, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 7095ea886..f5b8bdaa4 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,15 +1,19 @@ import { execSync } from "node:child_process"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, expect, test, vi } from "vitest"; import type { GitHubService } from "../services/github-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; -import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js"; +import { + attemptFirstAgentBranchAutoName, + createPaseoWorktree, + type CreatePaseoWorktreeDeps, +} from "./paseo-worktree-service.js"; import { createWorktreeCoreDeps } from "./worktree-core.js"; +import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; const cleanupPaths: string[] = []; @@ -98,6 +102,150 @@ test("reuses an existing worktree and still upserts the workspace", async () => expect(events).toContain(`workspace:${second.workspace.workspaceId}`); }); +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 created = await createPaseoWorktree( + { + cwd: repoDir, + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(created.worktree.branchName).toBe("unnamed-placeholder"); + expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + version: 2, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName: "unnamed-placeholder", + }, + }); + + const first = await attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + nameContext: "Build the agent context name", + generateBranchName: deps.generateBranchName, + }); + const branchAfterFirst = execSync("git branch --show-current", { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + expect(first).toEqual({ + attempted: true, + renamed: true, + branchName: "renamed-from-agent-context", + }); + expect(branchAfterFirst).toBe("renamed-from-agent-context"); + expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + version: 2, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: "unnamed-placeholder", + }, + }); + + const second = await attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + nameContext: "Try another name", + generateBranchName: () => "second-agent-name", + }); + const branchAfterSecond = execSync("git branch --show-current", { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + expect(second).toEqual({ attempted: false, renamed: false, branchName: null }); + expect(branchAfterSecond).toBe("renamed-from-agent-context"); +}); + +test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" }); + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + + const created = await createPaseoWorktree( + { + cwd: repoDir, + action: "checkout", + refName: "dev", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + version: 1, + baseRefName: "dev", + }); + await expect( + attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + nameContext: "Rename checkout branch", + generateBranchName: () => "must-not-rename", + }), + ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); + expect( + execSync("git branch --show-current", { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("dev"); +}); + +test("does not mark GitHub PR checkout worktrees as eligible for first-agent rename", async () => { + const { repoDir, tempDir } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + + const created = await createPaseoWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 123, + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + version: 1, + baseRefName: "main", + }); + await expect( + attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + nameContext: "Rename PR checkout", + generateBranchName: () => "must-not-rename", + }), + ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); + expect( + execSync("git branch --show-current", { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("pr-123"); +}); + test("does not mutate registries or broadcast when core worktree creation fails", async () => { const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); cleanupPaths.push(tempDir); @@ -119,21 +267,6 @@ test("does not mutate registries or broadcast when core worktree creation fails" expect(deps.workspaces.size).toBe(0); }); -test("keeps direct core worktree creation calls behind the service boundary", () => { - // Keep this literal in the test file so the grep invariant sees createWorktreeCore( here. - const serverSrc = path.dirname(fileURLToPath(import.meta.url)); - const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => { - if (path.basename(filePath) === "worktree-core.ts") { - return []; - } - const contents = readFileSync(filePath, "utf8"); - const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g"); - return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath)); - }); - - expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]); -}); - interface TestDeps extends CreatePaseoWorktreeDeps { projects: Map; workspaces: Map; @@ -143,6 +276,7 @@ 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(); @@ -150,6 +284,7 @@ function createDeps(options?: { return { ...createWorktreeCoreDeps(createGitHubServiceStub()), + ...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}), projects, workspaces, projectRegistry: { @@ -310,14 +445,24 @@ function createGitRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } -function listTypeScriptFiles(directory: string): string[] { - const entries = readdirSync(directory); - return entries.flatMap((entry) => { - const fullPath = path.join(directory, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - return listTypeScriptFiles(fullPath); - } - return fullPath.endsWith(".ts") ? [fullPath] : []; +function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { + const { tempDir, repoDir } = createGitRepo(); + execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "pr branch\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" }); + const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim(); + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" }); + + const remoteDir = path.join(tempDir, "remote.git"); + execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { + stdio: "pipe", }); + execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, { + stdio: "pipe", + }); + execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); + execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); + return { tempDir, repoDir }; } diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index f6ea174df..7ed166d97 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,3 +1,5 @@ +import { basename } from "node:path"; + import type { WorkspaceGitService } from "./workspace-git-service.js"; import { type PersistedWorkspaceRecord, @@ -13,6 +15,13 @@ import { 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 { + markPaseoWorktreeFirstAgentBranchAutoNameAttempted, + readPaseoWorktreeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, +} from "../utils/worktree-metadata.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {} @@ -32,6 +41,12 @@ export type CreatePaseoWorktreeFn = ( }, ) => Promise; +export interface AttemptFirstAgentBranchAutoNameResult { + attempted: boolean; + renamed: boolean; + branchName: string | null; +} + export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { projectRegistry: Pick; workspaceRegistry: Pick; @@ -43,17 +58,23 @@ export async function createPaseoWorktree( deps: CreatePaseoWorktreeDeps, ): Promise { const createdWorktree = await createWorktreeCore(input, deps); + maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree }); + const worktree = await maybeAutoNameCreatedWorktree({ + input, + createdWorktree, + deps, + }); const workspace = await upsertWorkspaceForWorktree({ inputCwd: input.cwd, repoRoot: createdWorktree.repoRoot, - worktree: createdWorktree.worktree, + worktree, deps, }); - deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); + deps.github.invalidate({ cwd: worktree.worktreePath }); return { - worktree: createdWorktree.worktree, + worktree, intent: createdWorktree.intent, workspace, repoRoot: createdWorktree.repoRoot, @@ -61,6 +82,108 @@ export async function createPaseoWorktree( }; } +export async function attemptFirstAgentBranchAutoName(options: { + cwd: string; + nameContext?: string; + generateBranchName: (seed: string | undefined) => string; + renameCurrentBranch?: typeof renameCurrentBranch; +}): Promise { + const nameContext = options.nameContext?.trim(); + if (!nameContext) { + return { attempted: false, renamed: false, branchName: null }; + } + + let metadata: ReturnType; + try { + metadata = readPaseoWorktreeMetadata(options.cwd); + } catch { + return { attempted: false, renamed: false, branchName: null }; + } + if ( + !metadata || + metadata.version !== 2 || + metadata.firstAgentBranchAutoName?.status !== "pending" + ) { + return { attempted: false, renamed: false, branchName: null }; + } + + markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + + const branchName = options.generateBranchName(nameContext); + const validation = validateBranchSlug(branchName); + if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) { + return { attempted: true, renamed: false, branchName: null }; + } + + const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch; + const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName); + return { + attempted: true, + renamed: true, + branchName: renamedBranch.currentBranch ?? branchName, + }; +} + +function maybeMarkFirstAgentBranchAutoNameEligible(options: { + input: CreatePaseoWorktreeInput; + createdWorktree: Awaited>; +}): void { + const { input, createdWorktree } = options; + if ( + !createdWorktree.created || + input.worktreeSlug || + createdWorktree.intent.kind !== "branch-off" + ) { + return; + } + + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { + placeholderBranchName: createdWorktree.worktree.branchName, + }); +} + +async function maybeAutoNameCreatedWorktree(options: { + input: CreatePaseoWorktreeInput; + createdWorktree: Awaited>; + deps: Pick; +}): Promise { + const { input, createdWorktree, deps } = options; + const nameContext = input.nameContext?.trim(); + if ( + !nameContext || + input.worktreeSlug || + !createdWorktree.created || + createdWorktree.intent.kind !== "branch-off" + ) { + return createdWorktree.worktree; + } + + const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath); + if ( + !generatedPlaceholderName || + createdWorktree.worktree.branchName !== generatedPlaceholderName + ) { + return createdWorktree.worktree; + } + + markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath); + + const branchName = deps.generateBranchName(nameContext); + 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/session.ts b/packages/server/src/server/session.ts index 62fd7a249..ce2c5734c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -165,13 +165,11 @@ import { } from "./file-explorer/service.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; -import { type WorktreeConfig } from "../utils/worktree.js"; import { readPaseoConfigForEdit, writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../utils/paseo-config-file.js"; -import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ScriptRouteStore } from "./script-proxy.js"; @@ -206,6 +204,7 @@ import { type PullRequestTimelineItem, } from "../services/github-service.js"; import { + attemptFirstAgentBranchAutoName, createPaseoWorktree, type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, @@ -214,7 +213,10 @@ import { createWorktreeCoreDeps } from "./worktree-core.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, - runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession, + buildAgentWorktreeNameContext, + createPaseoWorktreeWorkflow as createWorktreeWorkflow, + type CreatePaseoWorktreeSetupContinuationInput, + type CreatePaseoWorktreeWorkflowResult, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, @@ -274,6 +276,7 @@ type GitMutationRefreshReason = | "create-pr" | "switch-branch" | "create-branch" + | "rename-branch" | "stash-push" | "stash-pop" | "create-worktree"; @@ -3105,19 +3108,28 @@ export class Session { ...(provisionalTitle ? { title: provisionalTitle } : {}), }; - const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig( + const agentNameContext = buildAgentWorktreeNameContext({ + initialPrompt: trimmedPrompt, + attachments, + }); + const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig( resolvedConfig, git, worktreeName, attachments, + agentNameContext, ); - const resolvedWorkspace = msg.workspaceId + let resolvedWorkspace = msg.workspaceId ? await this.workspaceRegistry.get(msg.workspaceId) : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); if (!resolvedWorkspace) { throw new Error(`Workspace not found: ${msg.workspaceId}`); } + resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({ + workspace: resolvedWorkspace, + nameContext: agentNameContext, + }); const snapshot = await this.agentManager.createAgent( { ...sessionConfig, @@ -3155,27 +3167,9 @@ export class Session { }); } - if (worktreeBootstrap) { - void runAsyncWorktreeBootstrap({ - agentId: snapshot.id, - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: this.terminalManager, - appendTimelineItem: (item) => - appendTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId: snapshot.id, - item, - }), - emitLiveTimelineItem: (item) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId: snapshot.id, - item, - }), - logger: this.sessionLogger, - }); - } + setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); this.sessionLogger.info( { agentId: snapshot.id, provider: snapshot.provider }, @@ -3231,9 +3225,6 @@ export class Session { explicitTitle: params.explicitTitle, paseoHome: this.paseoHome, logger: this.sessionLogger, - deps: { - workspaceGitService: this.workspaceGitService, - }, }); const started = await this.handleSendAgentMessage( @@ -3460,9 +3451,10 @@ export class Session { gitOptions?: GitSetupOptions, legacyWorktreeName?: string, attachments?: AgentAttachment[], + nameContext?: string, ): Promise<{ sessionConfig: AgentSessionConfig; - worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean }; + setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; }> { return buildWorktreeAgentSessionConfig( { @@ -3470,7 +3462,26 @@ export class Session { sessionLogger: this.sessionLogger, workspaceGitService: this.workspaceGitService, createPaseoWorktree: (input, serviceOptions) => - this.createPaseoWorktree(input, serviceOptions), + this.createPaseoWorktreeWorkflow(input, { + ...serviceOptions, + setupContinuation: { + kind: "agent", + terminalManager: this.terminalManager, + appendTimelineItem: ({ agentId, item }) => + appendTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + emitLiveTimelineItem: ({ agentId, item }) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + logger: this.sessionLogger, + }, + }), checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), createBranchFromBase: (params) => this.createBranchFromBase(params), github: this.github, @@ -3479,9 +3490,35 @@ export class Session { gitOptions, legacyWorktreeName, attachments, + nameContext, ); } + private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { + workspace: PersistedWorkspaceRecord; + nameContext?: string; + }): Promise { + const coreDeps = createWorktreeCoreDeps(this.github); + const result = await attemptFirstAgentBranchAutoName({ + cwd: input.workspace.cwd, + nameContext: input.nameContext, + generateBranchName: coreDeps.generateBranchName, + }); + if (!result.renamed || !result.branchName) { + return input.workspace; + } + + const updatedWorkspace: PersistedWorkspaceRecord = { + ...input.workspace, + displayName: result.branchName, + updatedAt: new Date().toISOString(), + }; + await this.workspaceRegistry.upsert(updatedWorkspace); + await this.notifyGitMutation(input.workspace.cwd, "rename-branch"); + await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); + return updatedWorkspace; + } + private emitProviderDisabledResponse( kind: "models" | "modes", provider: AgentProvider, @@ -7447,27 +7484,26 @@ export class Session { paseoHome: this.paseoHome, describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result), emit: (message) => this.emit(message), - createPaseoWorktree: (input) => this.createPaseoWorktree(input), - warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), sessionLogger: this.sessionLogger, - runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options), + createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input), }, request, ); } - private async runWorktreeSetupInBackground(options: { - requestCwd: string; - repoRoot: string; - workspaceId: string; - worktree: { branchName: string; worktreePath: string }; - shouldBootstrap: boolean; - slug: string; - worktreePath: string; - }): Promise { - return runWorktreeSetupInBackgroundSession( + private async createPaseoWorktreeWorkflow( + input: CreatePaseoWorktreeInput, + options?: { + resolveDefaultBranch?: (repoRoot: string) => Promise; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + }, + ): Promise { + return createWorktreeWorkflow( { paseoHome: this.paseoHome, + createPaseoWorktree: (workflowInput, serviceOptions) => + this.createPaseoWorktree(workflowInput, serviceOptions), + warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), emitWorkspaceUpdateForCwd: (cwd, emitOptions) => this.emitWorkspaceUpdateForCwd(cwd, emitOptions), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { @@ -7485,6 +7521,7 @@ export class Session { this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory); }, }, + input, options, ); } diff --git a/packages/server/src/server/worktree-core.test.ts b/packages/server/src/server/worktree-core.test.ts index 636fad844..1423ae7d2 100644 --- a/packages/server/src/server/worktree-core.test.ts +++ b/packages/server/src/server/worktree-core.test.ts @@ -2,7 +2,6 @@ import { execSync } from "node:child_process"; import { existsSync, mkdtempSync, - readdirSync, readFileSync, realpathSync, rmSync, @@ -10,7 +9,6 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { describe, expect, test, afterEach, vi } from "vitest"; import type { GitHubService } from "../services/github-service.js"; @@ -60,42 +58,6 @@ function createCoreDeps(options?: { }; } -function findDirectCreateWorktreeCallSites(serverSrc: string): string[] { - const matches: string[] = []; - - function walk(directory: string) { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const absolutePath = path.join(directory, entry.name); - - if (entry.isDirectory()) { - walk(absolutePath); - continue; - } - - if (!entry.isFile()) { - continue; - } - - const relativePath = path - .relative(serverSrc, absolutePath) - .split(path.sep) - .join(path.posix.sep); - - if (relativePath === "utils/worktree.ts" || relativePath.endsWith(".test.ts")) { - continue; - } - - // Keep this literal in the test file so the invariant proves tests are allowed to inspect createWorktree(. - if (/createWorktree\(/.test(readFileSync(absolutePath, "utf8"))) { - matches.push(relativePath); - } - } - } - - walk(serverSrc); - return matches.sort(); -} - function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-"))); const repoDir = path.join(tempDir, "repo"); @@ -651,11 +613,6 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { }); expect(result.worktree.branchName).toBe("feature/from-service"); }); - - test("keeps direct createWorktree calls isolated to the core layer", () => { - const serverSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]); - }); }); describe("resolveWorktreeRepoRoot", () => { diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index fc6b35714..8d2950f54 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -17,6 +17,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { cwd: string; + nameContext?: string; paseoHome?: string; runSetup?: boolean; } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index dc7b1140a..ccb3b3dae 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -16,6 +16,8 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; import { buildAgentSessionConfig, + buildAgentWorktreeNameContext, + createPaseoWorktreeWorkflow, handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeListRequest, resolveGitCreateBaseBranch, @@ -79,6 +81,53 @@ function createLogger(): Logger { return logger; } +function createWorkflowForRequestTest(options: { + paseoHome: string; + createPaseoWorktree?: CreatePaseoWorktreeFn; + warmWorkspaceGitData?: (workspace: PersistedWorkspaceRecord) => Promise; + onSetupStarted?: (input: { + requestCwd: string; + repoRoot: string; + workspaceId: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; + }) => void; +}) { + return async (input: Parameters[0]) => { + const createPaseoWorktree = + options.createPaseoWorktree ?? createPaseoWorktreeForTest({ paseoHome: options.paseoHome }); + return createPaseoWorktreeWorkflow( + { + paseoHome: options.paseoHome, + createPaseoWorktree, + warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}), + emitWorkspaceUpdateForCwd: async () => {}, + cacheWorkspaceSetupSnapshot: () => {}, + emit: () => {}, + sessionLogger: createLogger(), + terminalManager: null, + archiveWorkspaceRecord: async () => {}, + scriptRouteStore: null, + scriptRuntimeStore: null, + getDaemonTcpPort: null, + getDaemonTcpHost: null, + onScriptsChanged: null, + }, + input, + { setupContinuation: { kind: "workspace" } }, + ).then((result) => { + options.onSetupStarted?.({ + requestCwd: input.cwd, + repoRoot: result.repoRoot, + workspaceId: result.workspace.workspaceId, + worktree: result.worktree, + shouldBootstrap: result.created, + }); + return result; + }); + }; +} + function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], @@ -347,6 +396,81 @@ describe("resolveGitCreateBaseBranch", () => { }); }); +describe("create-agent worktree setup boundary", () => { + test("agent setup continuation starts setup for the created agent timeline", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const appendedItems: Array<{ name: string; status: string }> = []; + const liveItems: Array<{ name: string; status: string }> = []; + const workspaceSetupEvents: SessionOutboundMessage[] = []; + + try { + const result = await createPaseoWorktreeWorkflow( + { + paseoHome, + createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), + warmWorkspaceGitData: async () => {}, + emitWorkspaceUpdateForCwd: async () => {}, + cacheWorkspaceSetupSnapshot: () => {}, + emit: (message) => workspaceSetupEvents.push(message), + sessionLogger: createLogger(), + terminalManager: null, + archiveWorkspaceRecord: async () => {}, + scriptRouteStore: null, + scriptRuntimeStore: null, + getDaemonTcpPort: null, + getDaemonTcpHost: null, + onScriptsChanged: null, + }, + { + cwd: repoDir, + worktreeSlug: "agent-setup-after-create", + runSetup: false, + paseoHome, + }, + { + setupContinuation: { + kind: "agent", + terminalManager: createTerminalManagerStub().manager, + appendTimelineItem: async ({ agentId, item }) => { + expect(agentId).toBe("agent-after-create"); + if (item.type !== "tool_call") { + throw new Error(`Expected tool call timeline item, got ${item.type}`); + } + appendedItems.push({ name: item.name, status: item.status }); + return true; + }, + emitLiveTimelineItem: async ({ agentId, item }) => { + expect(agentId).toBe("agent-after-create"); + if (item.type !== "tool_call") { + throw new Error(`Expected tool call timeline item, got ${item.type}`); + } + liveItems.push({ name: item.name, status: item.status }); + return true; + }, + logger: createLogger(), + }, + }, + ); + + expect(result.setupContinuation?.kind).toBe("agent"); + expect(workspaceSetupEvents).toEqual([]); + + result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" }); + + await vi.waitFor(() => { + expect(appendedItems).toContainEqual({ + name: "paseo_worktree_setup", + status: "completed", + }); + }); + expect(liveItems).toEqual([]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + function createAgentStorageStub(): Pick { return { list: async (): Promise => [], @@ -1048,10 +1172,8 @@ describe("handleCreatePaseoWorktreeRequest", () => { describeWorkspaceRecord: async (result) => createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), emit: (message) => emitted.push(message), - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), - warmWorkspaceGitData: async () => {}, sessionLogger: logger, - runWorktreeSetupInBackground: async () => {}, + createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }), }, { type: "create_paseo_worktree_request", @@ -1139,8 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { ], ); - expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr"); - expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123"); + expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); const branch = execSync("git branch --show-current", { @@ -1184,8 +1305,93 @@ describe("handleCreatePaseoWorktreeRequest", () => { }, ); - expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature-x"); - expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("feature-x"); + expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x"); + }); + + test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => { + const createPaseoWorktree = vi.fn(async () => ({ + worktree: { + branchName: "fix-attached-pr-context", + worktreePath: "/tmp/worktrees/fix-attached-pr-context", + }, + intent: { + kind: "branch-off" as const, + baseBranch: "main", + newBranchName: "fix-attached-pr-context", + }, + workspace: { + workspaceId: "/tmp/worktrees/fix-attached-pr-context", + projectId: "/tmp/repo", + cwd: "/tmp/worktrees/fix-attached-pr-context", + kind: "worktree" as const, + displayName: "fix-attached-pr-context", + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + archivedAt: null, + }, + repoRoot: "/tmp/repo", + created: true, + })); + const nameContext = buildAgentWorktreeNameContext({ + initialPrompt: "Create a worktree name from this prompt", + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Fix worktree naming", + url: "https://github.com/getpaseo/paseo/pull/123", + baseRefName: "main", + headRefName: "fix/worktree-naming", + }, + ], + }); + + const result = await buildAgentSessionConfig( + { + sessionLogger: createLogger(), + workspaceGitService: { + resolveDefaultBranch: vi.fn(async () => "main"), + } as unknown as WorkspaceGitService, + createPaseoWorktree, + checkoutExistingBranch: async () => { + throw new Error("should not checkout existing branch"); + }, + createBranchFromBase: async () => { + throw new Error("should not create a branch outside the worktree service"); + }, + }, + { + provider: "codex", + cwd: "/tmp/repo", + }, + { + createWorktree: true, + action: "branch-off", + }, + undefined, + [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Fix worktree naming", + url: "https://github.com/getpaseo/paseo/pull/123", + baseRefName: "main", + headRefName: "fix/worktree-naming", + }, + ], + nameContext, + ); + + expect(createPaseoWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + nameContext: + "Create a worktree name from this prompt\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming", + }), + expect.anything(), + ); + expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context"); }); test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { @@ -1284,7 +1490,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { paseoHome, sessionLogger: createLogger(), emit: (message) => emitted.push(message), - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }), + createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ + paseoHome, + createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }), + }), describeWorkspaceRecord: vi.fn(async (result) => ({ id: result.workspace.workspaceId, projectId: result.workspace.projectId, @@ -1308,8 +1517,6 @@ describe("handleCreatePaseoWorktreeRequest", () => { }, githubRuntime: null, })), - warmWorkspaceGitData: async () => {}, - runWorktreeSetupInBackground: vi.fn(async () => {}), }, { type: "create_paseo_worktree_request", @@ -1346,17 +1553,20 @@ describe("handleCreatePaseoWorktreeRequest", () => { paseoHome, sessionLogger: createLogger(), emit: (message) => emitted.push(message), - createPaseoWorktree: async (input) => { - const result = await createPaseoWorktreeForTest({ paseoHome })(input); - expect(existsSync(result.worktree.worktreePath)).toBe(true); - registeredWorktreePath = result.worktree.worktreePath; - return result; - }, + createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ + paseoHome, + createPaseoWorktree: async (input) => { + const result = await createPaseoWorktreeForTest({ paseoHome })(input); + expect(existsSync(result.worktree.worktreePath)).toBe(true); + registeredWorktreePath = result.worktree.worktreePath; + return result; + }, + warmWorkspaceGitData, + onSetupStarted: backgroundWork, + }), describeWorkspaceRecord: vi.fn(async (result) => createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), ), - warmWorkspaceGitData, - runWorktreeSetupInBackground: backgroundWork, }, { type: "create_paseo_worktree_request", @@ -1388,6 +1598,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { }); expect(registeredWorktreePath).toBeTruthy(); expect(existsSync(registeredWorktreePath!)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(warmWorkspaceGitData).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: response?.payload.workspace?.id, @@ -1421,12 +1632,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { paseoHome, sessionLogger: createLogger(), emit: (message) => emitted.push(message), - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), + createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }), describeWorkspaceRecord: vi.fn(async (result) => createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), ), - warmWorkspaceGitData: async () => {}, - runWorktreeSetupInBackground: vi.fn(async () => {}), }, { type: "create_paseo_worktree_request", @@ -1462,12 +1671,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { paseoHome, sessionLogger: createLogger(), emit: (message) => emitted.push(message), - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), + createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }), describeWorkspaceRecord: vi.fn(async (result) => createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), ), - warmWorkspaceGitData: async () => {}, - runWorktreeSetupInBackground: vi.fn(async () => {}), }, { type: "create_paseo_worktree_request", diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index d1578def5..4e4d9b613 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -13,6 +13,7 @@ import { import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { + runAsyncWorktreeBootstrap, applyWorktreeSetupProgressEvent, buildWorktreeSetupDetail, createWorktreeSetupProgressAccumulator, @@ -23,6 +24,7 @@ import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { GitHubService } from "../services/github-service.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; +import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js"; import { expandTilde } from "../utils/path.js"; import { getWorktreeSetupCommands, @@ -61,6 +63,15 @@ export interface NormalizedGitOptions { } type EmitSessionMessage = (message: SessionOutboundMessage) => void; +type AgentWorktreeSetupTimelineItem = Parameters< + typeof runAsyncWorktreeBootstrap +>[0]["appendTimelineItem"] extends (item: infer Item) => unknown + ? Item + : never; +type AgentWorktreeSetupTimelineWriter = (input: { + agentId: string; + item: AgentWorktreeSetupTimelineItem; +}) => Promise; interface BuildAgentSessionConfigDependencies { paseoHome?: string; @@ -70,8 +81,9 @@ interface BuildAgentSessionConfigDependencies { input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, - ) => Promise; + ) => Promise; checkoutExistingBranch: (cwd: string, branch: string) => Promise; createBranchFromBase: (params: { cwd: string; @@ -96,6 +108,45 @@ interface CreatePaseoWorktreeInBackgroundDependencies { onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null; } +interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies { + createPaseoWorktree: ( + input: CreatePaseoWorktreeInput, + options?: { + resolveDefaultBranch?: (repoRoot: string) => Promise; + }, + ) => Promise; + warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise; +} + +interface AgentWorktreeSetupContinuationInput { + kind: "agent"; + terminalManager: TerminalManager | null; + appendTimelineItem: AgentWorktreeSetupTimelineWriter; + emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter; + logger: Logger; +} + +export type CreatePaseoWorktreeSetupContinuationInput = + | { kind: "workspace" } + | AgentWorktreeSetupContinuationInput; + +export interface AgentWorktreeSetupContinuation { + kind: "agent"; + startAfterAgentCreate: (input: { agentId: string }) => void; +} + +export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & { + setupContinuation?: AgentWorktreeSetupContinuation; +}; + +export type CreatePaseoWorktreeWorkflowFn = ( + input: CreatePaseoWorktreeInput, + options?: { + resolveDefaultBranch?: (repoRoot: string) => Promise; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + }, +) => Promise; + interface HandleWorkspaceSetupStatusRequestDependencies { emit: EmitSessionMessage; workspaceSetupSnapshots: ReadonlyMap; @@ -107,18 +158,10 @@ interface HandleCreatePaseoWorktreeRequestDependencies { result: CreatePaseoWorktreeResult, ) => Promise; emit: EmitSessionMessage; - createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise; - warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise; sessionLogger: Logger; - runWorktreeSetupInBackground: (options: { - requestCwd: string; - repoRoot: string; - workspaceId: string; - worktree: WorktreeConfig; - shouldBootstrap: boolean; - slug: string; - worktreePath: string; - }) => Promise; + createPaseoWorktreeWorkflow: ( + input: CreatePaseoWorktreeInput, + ) => Promise; } export async function buildAgentSessionConfig( @@ -127,13 +170,14 @@ export async function buildAgentSessionConfig( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, attachments?: AgentAttachment[], + nameContext?: string, ): Promise<{ sessionConfig: AgentSessionConfig; - worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean }; + setupContinuation?: AgentWorktreeSetupContinuation; }> { let cwd = expandTilde(config.cwd); const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); - let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined; + let setupContinuation: AgentWorktreeSetupContinuation | undefined; if (!normalized) { return { @@ -157,6 +201,7 @@ export async function buildAgentSessionConfig( refName: normalized.refName, action: normalized.action, githubPrNumber: normalized.githubPrNumber, + nameContext, attachments, runSetup: false, paseoHome: dependencies.paseoHome, @@ -173,10 +218,7 @@ export async function buildAgentSessionConfig( }, ); cwd = createdWorktree.worktree.worktreePath; - worktreeBootstrap = { - worktree: createdWorktree.worktree, - shouldBootstrap: createdWorktree.created, - }; + setupContinuation = createdWorktree.setupContinuation; } else if (normalized.createNewBranch) { const baseBranch = normalized.baseBranch ?? @@ -201,10 +243,28 @@ export async function buildAgentSessionConfig( ...config, cwd, }, - worktreeBootstrap, + setupContinuation, }; } +export function buildAgentWorktreeNameContext(input: { + initialPrompt?: string | null; + attachments?: readonly AgentAttachment[]; +}): string | undefined { + const parts: string[] = []; + const prompt = input.initialPrompt?.trim(); + if (prompt) { + parts.push(prompt); + } + for (const attachment of input.attachments ?? []) { + const rendered = renderPromptAttachmentAsText(attachment).trim(); + if (rendered) { + parts.push(rendered); + } + } + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + interface ValidateNormalizedGitOptionsInput { baseBranch: string | undefined; createNewBranch: boolean; @@ -450,9 +510,10 @@ export async function handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { try { - const createdWorktree = await dependencies.createPaseoWorktree({ + const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ cwd: request.cwd, worktreeSlug: request.worktreeSlug, + nameContext: request.nameContext, refName: request.refName, action: request.action, githubPrNumber: request.githubPrNumber, @@ -460,8 +521,6 @@ export async function handleCreatePaseoWorktreeRequest( runSetup: false, paseoHome: dependencies.paseoHome, }); - const slug = basename(createdWorktree.worktree.worktreePath); - const workspace = createdWorktree.workspace; const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree); dependencies.emit({ @@ -480,22 +539,6 @@ export async function handleCreatePaseoWorktreeRequest( workspace: descriptor, }, }); - - void dependencies.warmWorkspaceGitData(workspace).catch((error) => { - dependencies.sessionLogger.warn( - { err: error, workspaceId: workspace.workspaceId }, - "Failed to warm workspace git data after creating worktree", - ); - }); - void dependencies.runWorktreeSetupInBackground({ - requestCwd: request.cwd, - repoRoot: createdWorktree.repoRoot, - workspaceId: workspace.workspaceId, - worktree: createdWorktree.worktree, - shouldBootstrap: createdWorktree.created, - slug, - worktreePath: createdWorktree.worktree.worktreePath, - }); } catch (error) { const wireError = toWorktreeWireError(error); dependencies.sessionLogger.error( @@ -515,6 +558,72 @@ export async function handleCreatePaseoWorktreeRequest( } } +export async function createPaseoWorktreeWorkflow( + dependencies: CreatePaseoWorktreeWorkflowDependencies, + input: CreatePaseoWorktreeInput, + options?: { + resolveDefaultBranch?: (repoRoot: string) => Promise; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + }, +): Promise { + const createdWorktree = await dependencies.createPaseoWorktree( + { + ...input, + runSetup: false, + paseoHome: input.paseoHome ?? dependencies.paseoHome, + }, + options?.resolveDefaultBranch + ? { resolveDefaultBranch: options.resolveDefaultBranch } + : undefined, + ); + const slug = basename(createdWorktree.worktree.worktreePath); + const workspace = createdWorktree.workspace; + const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; + + setTimeout(() => { + void dependencies.warmWorkspaceGitData(workspace).catch((error) => { + dependencies.sessionLogger.warn( + { err: error, workspaceId: workspace.workspaceId }, + "Failed to warm workspace git data after creating worktree", + ); + }); + if (setupContinuation.kind === "workspace") { + void runWorktreeSetupInBackground(dependencies, { + requestCwd: input.cwd, + repoRoot: createdWorktree.repoRoot, + workspaceId: workspace.workspaceId, + worktree: createdWorktree.worktree, + shouldBootstrap: createdWorktree.created, + slug, + worktreePath: createdWorktree.worktree.worktreePath, + }); + } + }, 0); + + if (setupContinuation.kind === "agent") { + return { + ...createdWorktree, + setupContinuation: { + kind: "agent", + startAfterAgentCreate: ({ agentId }) => { + void runAsyncWorktreeBootstrap({ + agentId, + worktree: createdWorktree.worktree, + shouldBootstrap: createdWorktree.created, + terminalManager: setupContinuation.terminalManager, + appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }), + emitLiveTimelineItem: (item) => + setupContinuation.emitLiveTimelineItem({ agentId, item }), + logger: setupContinuation.logger, + }); + }, + }, + }; + } + + return createdWorktree; +} + export async function handleWorkspaceSetupStatusRequest( dependencies: HandleWorkspaceSetupStatusRequestDependencies, request: Extract, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 00e05b9bf..70d744f86 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1385,6 +1385,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ type: z.literal("create_paseo_worktree_request"), cwd: z.string(), worktreeSlug: z.string().optional(), + nameContext: z.string().optional(), attachments: AgentAttachmentsSchema, refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index f40c5e180..a8be30586 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -10,6 +10,19 @@ const PaseoWorktreeMetadataV1Schema = z.object({ const PaseoWorktreeMetadataV2Schema = z.object({ version: z.literal(2), baseRefName: z.string().min(1), + firstAgentBranchAutoName: z + .discriminatedUnion("status", [ + z.object({ + status: z.literal("pending"), + placeholderBranchName: z.string().min(1), + }), + z.object({ + status: z.literal("attempted"), + placeholderBranchName: z.string().min(1), + attemptedAt: z.string().min(1), + }), + ]) + .optional(), runtime: z .object({ worktreePort: z.number().int().positive(), @@ -101,6 +114,9 @@ export function writePaseoWorktreeRuntimeMetadata( const next: PaseoWorktreeMetadata = { version: 2, baseRefName: current.baseRefName, + ...(current.version === 2 && current.firstAgentBranchAutoName + ? { firstAgentBranchAutoName: current.firstAgentBranchAutoName } + : {}), runtime: { worktreePort: options.worktreePort, }, @@ -108,6 +124,54 @@ export function writePaseoWorktreeRuntimeMetadata( writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); } +export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( + worktreeRoot: string, + options: { placeholderBranchName: string }, +): void { + const placeholderBranchName = options.placeholderBranchName.trim(); + if (!placeholderBranchName) { + throw new Error("Placeholder branch name is required"); + } + + const current = readPaseoWorktreeMetadata(worktreeRoot); + if (!current) { + throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata"); + } + + writePaseoWorktreeMetadataFile(worktreeRoot, { + version: 2, + baseRefName: current.baseRefName, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName, + }, + ...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}), + }); +} + +export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( + worktreeRoot: string, + options: { attemptedAt?: string } = {}, +): PaseoWorktreeMetadata | null { + const current = readPaseoWorktreeMetadata(worktreeRoot); + if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") { + return current; + } + + const next: PaseoWorktreeMetadata = { + version: 2, + baseRefName: current.baseRefName, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, + attemptedAt: options.attemptedAt ?? new Date().toISOString(), + }, + ...(current.runtime ? { runtime: current.runtime } : {}), + }; + writePaseoWorktreeMetadataFile(worktreeRoot, next); + return next; +} + export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); if (!existsSync(metadataPath)) { @@ -136,3 +200,12 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul } return null; } + +function writePaseoWorktreeMetadataFile( + worktreeRoot: string, + metadata: PaseoWorktreeMetadata, +): void { + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); + writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); +}