diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 378a0070ea..b49e8309db 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it. Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. +Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. + Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index a17480b740..177ddd563e 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "../support/fixtures"; +import type { Page } from "@playwright/test"; import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { @@ -31,6 +32,28 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; +async function showDeveloperToast(page: Page): Promise { + await page.evaluate(() => { + document.getElementById("error-toast")?.remove(); + const toast = document.createElement("div"); + toast.id = "error-toast"; + toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white"; + // Expo renders its unnamed dismiss button in an open shadow root. + const button = document.createElement("button"); + button.style.cssText = "width:24px;height:24px"; + button.addEventListener("click", () => toast.remove()); + toast.attachShadow({ mode: "open" }).appendChild(button); + document.body.appendChild(toast); + }); +} + +test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => { + await gotoAppShell(page); + await showDeveloperToast(page); + await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]); + await expect(page.locator("#error-toast")).toBeHidden(); +}); + test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); diff --git a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts index 17c4d3dd45..969f330520 100644 --- a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts +++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures"; import { createTempGitRepo } from "../support/helpers/workspace"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, openWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, @@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page await seedProjectForWorkspaceSetup(client, repo.path); const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 227e2c8bc1..18a9704ddf 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -18,6 +18,7 @@ import { } from "../support/helpers/terminal-perf"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, expectSetupPanel, openHomeWithProject, @@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const initialRunning = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log === "", + (payload) => payload.status === "running" && payload.detail.commands.length === 0, ); const runningWithOutput = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"), + (payload) => + payload.status === "running" && + setupCommandOutput(payload.detail).includes("starting setup"), ); const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); await createWorkspaceThroughDaemon(client, { @@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => { const completedPayload = await completed; expect(initialPayload.detail.log).toBe(""); - expect(runningPayload.detail.log).toContain("starting setup"); - expect(completedPayload.detail.log).toContain("setup complete"); + expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete"); expect(completedPayload.error).toBeNull(); } finally { await client.close(); @@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const failed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"), + (payload) => + payload.status === "failed" && + setupCommandOutput(payload.detail).includes("setup failed"), ); const workspace = await createWorkspaceThroughDaemon(client, { @@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => { }); const failedPayload = await failed; - expect(failedPayload.detail.log).toContain("starting setup"); - expect(failedPayload.detail.log).toContain("setup failed"); + expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed"); expect(failedPayload.error).toMatch(/failed/i); await openHomeWithProject(page, repo.path); @@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const result = await client.createPaseoWorktree({ diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index dd1bc779bd..eda1d8d023 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) { }, }; } + +export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string { + return detail.commands.map((command) => command.log).join("\n"); +} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index d67eb15f26..a10bd7b0c6 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { View, Text, @@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -interface WorktreeSetupDetailProps { - log: string; - branchName: string; - worktreePath: string; - ds: DetailStyles; -} - -function WorktreeSetupDetailSection({ - log, - branchName, - worktreePath, - ds, -}: WorktreeSetupDetailProps) { - const setupLog = log.replace(/^\n+/, ""); - const hasLog = setupLog.length > 0; - return ( - - - - - - - {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} - - - - - - - ); -} - function resolveSubAgentFallbackHeader( subAgentType: string | null | undefined, description: string | null | undefined, @@ -678,17 +636,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") { - return [ - , - ]; - } + if (detail.type === "worktree_setup") return []; if (detail.type === "sub_agent") { return [ ; manuallyCollapsed: Set; snapshotError: string | null | undefined; } function buildCommandRowState(args: BuildCommandRowPropsArgs) { - const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args; + const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args; const isExpanded = expandedIndices.has(command.index); const hasError = command.status === "failed" && Boolean(snapshotError); - const commandLog = resolveCommandLog(command, autoExpandIndex, log); + const commandLog = command.log; const hasLog = commandLog.trim().length > 0; const isExpandable = command.status !== "running" || hasLog || hasError; const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); @@ -285,9 +272,7 @@ function SetupPanel() { ); const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS; - const log = snapshot?.detail.log ?? ""; - const hasNoSetupCommands = - snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0; const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands(); @@ -323,7 +308,6 @@ function SetupPanel() { workspaceId={target.workspaceId} snapshot={snapshot} commands={commands} - log={log} isWaiting={isWaiting} hasNoSetupCommands={hasNoSetupCommands} autoExpandIndex={autoExpandIndex} @@ -341,7 +325,6 @@ interface SetupPanelBodyProps { workspaceId: string; snapshot: WorkspaceSetupSnapshot | null; commands: SetupCommand[]; - log: string; isWaiting: boolean; hasNoSetupCommands: boolean; autoExpandIndex: number | null; @@ -352,7 +335,7 @@ interface SetupPanelBodyProps { function SetupPanelBody(props: SetupPanelBodyProps) { const { t } = useTranslation(); - const { snapshot, commands, log } = props; + const { snapshot, commands } = props; if (snapshot?.status === "blocked" && snapshot.blockedSource) { return ( ); })} - ); @@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) { ); } -function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { - const { t } = useTranslation(); - if (commands.length !== 0 || log.trim().length === 0) return null; - return ( - - - {log} - - - ); -} - function TopLevelSetupError({ snapshotError, commands, diff --git a/packages/app/src/utils/tool-call-detail-state.test.ts b/packages/app/src/utils/tool-call-detail-state.test.ts index 1cf7d624dc..ddfed7c803 100644 --- a/packages/app/src/utils/tool-call-detail-state.test.ts +++ b/packages/app/src/utils/tool-call-detail-state.test.ts @@ -77,3 +77,16 @@ describe("tool-call detail state", () => { assert.strictEqual(hasMeaningfulToolCallDetail(detail), true); }); }); + +it("keeps historical setup rows readable as history without a chat output view", () => { + assert.strictEqual( + hasMeaningfulToolCallDetail({ + type: "worktree_setup", + branchName: "old", + worktreePath: "/workspace", + log: "historical output", + commands: [], + }), + false, + ); +}); diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index de9e151427..e2d983d8cb 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract, -): boolean { - return Boolean(detail.branchName || detail.worktreePath || detail.log); -} - function hasMeaningfulSubAgentDetail( detail: Extract, ): boolean { @@ -81,7 +75,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): case "fetch": return hasMeaningfulFetchDetail(detail); case "worktree_setup": - return hasMeaningfulWorktreeSetupDetail(detail); + return false; case "sub_agent": return hasMeaningfulSubAgentDetail(detail); case "plain_text": @@ -98,6 +92,7 @@ export function isPendingToolCallDetail(params: { status: "executing" | "running" | "completed" | "failed" | "canceled"; error: unknown; }): boolean { + if (params.detail?.type === "worktree_setup") return false; const isRunning = params.status === "running" || params.status === "executing"; return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail); } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7f183ccfa4..a172af0380 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3372,62 +3372,6 @@ test("sends create_agent_request with workspace and caller identity", async () = await expect(createPromise).rejects.toThrow("compat test sentinel"); }); -test("sends worktree target and autoArchive in create_agent_request", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - worktree: { - mode: "branch-off", - newBranch: "agent-lifecycle-dispatch", - base: "main", - }, - autoArchive: true, - }); - - expect(mock.sent).toHaveLength(1); - const request = parseSentFrame(mock.sent[0]); - expect(request).toEqual( - expect.objectContaining({ - type: "create_agent_request", - worktree: { - mode: "branch-off", - newBranch: "agent-lifecycle-dispatch", - base: "main", - }, - autoArchive: true, - }), - ); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.requestId, - error: "worktree auto archive sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); -}); - test("sends internal in create_agent_request when the host advertises internal agents", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3646,120 +3590,6 @@ test("sends structured attachments with create_agent_request", async () => { await expect(createPromise).rejects.toThrow("attachment test sentinel"); }); -test("sends worktree base-ref fields in create_agent_request git options", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-ref", - git: { - createWorktree: true, - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }, - }); - - expect(mock.sent).toHaveLength(1); - const request = parseSentFrame(mock.sent[0]); - expect(request.git).toEqual({ - createWorktree: true, - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.requestId, - error: "git ref fields sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("git ref fields sentinel"); -}); - -test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-legacy", - git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - }); - - expect(assertStr(mock.sent[0])).toBe( - JSON.stringify({ - type: "session", - message: { - type: "create_agent_request", - config: { - provider: "codex", - cwd: "/tmp/project", - }, - git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - labels: {}, - requestId: "req-agent-legacy", - }, - }), - ); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: "req-agent-legacy", - error: "legacy git shape sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("legacy git shape sentinel"); -}); - test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7881,3 +7711,59 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l expect(mock.sent).toHaveLength(1); expect(client.getConnectionState().status).toBe("connected"); }); + +test("sends worktree target and autoArchive in create_agent_request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + }); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual( + expect.objectContaining({ + type: "create_agent_request", + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "worktree auto archive sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index aa4624338e..29990cb428 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -459,12 +459,12 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { outputSchema?: Record; images?: CreateAgentRequestMessage["images"]; attachments?: CreateAgentRequestMessage["attachments"]; + /** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */ git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; background?: CreateAgentRequestMessage["background"]; - // COMPAT(createAgentWorktree): low-level old callers may still send the - // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. + /** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */ worktreeName?: string; requestId?: string; labels?: Record; @@ -2963,6 +2963,11 @@ export class DaemonClient { } async createAgent(options: CreateAgentRequestOptions): Promise { + if (options.git !== undefined || options.worktreeName !== undefined) { + throw new Error( + "Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.", + ); + } if (options.background !== undefined) this.requireBackgroundWorkspaces(); const result = await this.creations.createAgent({ ...options, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 6903aa2cd1..d3b3ce26df 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -474,3 +474,54 @@ test("usage entries accept older failures and additive successful snapshots", () }).success, ).toBe(false); }); + +// Frozen setup detail shape from v0.8.0: log is required, command output is optional. +const ReleasedSetupDetailSchema = z.object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array( + z.object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: z.string().optional().default(""), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }), + ), + truncated: z.boolean().optional(), +}); + +test("released setup clients parse empty combined logs and retain command output", () => { + const detail = { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "feature", + log: "", + commands: [ + { + index: 1, + command: "echo ready", + cwd: "/workspace", + log: "ready\n", + status: "completed", + exitCode: 0, + }, + ], + }; + const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail))); + expect(parsed).toEqual(detail); + expect(parsed.commands[0]?.log).toBe("ready\n"); + const { log: _log, ...withoutLegacyField } = detail; + expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false); + expect( + WorkspaceSetupSnapshotSchema.parse({ + status: "completed", + detail: { ...detail, log: "old daemon combined output" }, + error: null, + }).detail.log, + ).toBe("old daemon combined output"); +}); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index cf9711a9df..b123fa30c3 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); - test("create_agent with worktree is async and boots terminals only after setup success", async () => { + test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-")); @@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => { stdio: "pipe", }); - const result = await withTimeout({ + const workspaceResult = await withTimeout({ promise: client.callTool({ - name: "create_agent", + name: "create_workspace", args: { - cwd: repoRoot, - title: "MCP worktree setup terminals", - provider: "claude/claude-test-model", - mode: "bypassPermissions", - initialPrompt: "say done and stop", - worktreeName: "mcp-worktree-setup-test", + isolation: "worktree", + path: repoRoot, + worktreeSlug: "mcp-worktree-setup-test", baseBranch: "main", }, }), timeoutMs: 2500, - label: "create_agent should not block on setup", + label: "create_workspace should not block on setup", + }); + const workspace = getStructuredContent(workspaceResult); + if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace"); + const result = await client.callTool({ + name: "create_agent", + args: { + workspaceId: workspace.workspaceId, + title: "MCP worktree setup terminals", + provider: "claude/claude-test-model", + settings: { modeId: "bypassPermissions" }, + initialPrompt: "say done and stop", + }, }); const payload = getStructuredContent(result); diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 554bb9869e..2cbf71313b 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -1,3 +1,4 @@ +import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; import { randomUUID } from "node:crypto"; import type pino from "pino"; @@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, - CreatePaseoWorktreeSetupContinuationInput, - NormalizedGitOptions, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js"; +import type { + FirstAgentContext, + CreateAgentWorktreeTarget, + SessionOutboundMessage, +} from "../messages.js"; import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch { async createWorktreeForRequest(input: { cwd: string; - target: NormalizedGitOptions | null; + target: CreateAgentWorktreeTarget | undefined; firstAgentContext: FirstAgentContext; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; background?: boolean; callerWorkspaceId?: string; }): Promise { const target = input.target; - if (!target?.createWorktree) return null; + if (!target) return null; + const placement: CreatePaseoWorktreeInput = { cwd: input.cwd }; + switch (target.mode) { + case "branch-off": + placement.action = "branch-off"; + placement.worktreeSlug = target.newBranch; + placement.refName = target.base; + break; + case "checkout-branch": + placement.action = "checkout"; + placement.refName = target.branch; + break; + case "checkout-pr": + placement.githubPrNumber = target.prNumber; + break; + } + const base = target.mode === "branch-off" ? target.base : undefined; return this.dependencies.createPaseoWorktreeWorkflow( { - cwd: input.cwd, + ...placement, background: input.background, callerWorkspaceId: input.callerWorkspaceId, firstAgentContext: input.firstAgentContext, - worktreeSlug: target.worktreeSlug, - refName: target.refName, - action: target.action, - checkoutSource: target.checkoutSource, - githubPrNumber: target.githubPrNumber, - runSetup: false, paseoHome: this.dependencies.paseoHome, worktreesRoot: this.dependencies.worktreesRoot, }, - { - ...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}), - setupContinuation: input.setupContinuation, - }, + base ? { resolveDefaultBranch: async () => base } : undefined, ); } diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index f12309da2a..5d2c9fddc1 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; -import type { AgentSessionConfig } from "../agent-sdk-types.js"; const logger = createTestLogger(); @@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd }, repoRoot: args.repoRoot, created: true, - setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} }, }) as unknown as CreatePaseoWorktreeWorkflowResult; } @@ -88,7 +86,6 @@ test("session create forwards clientMessageId to the initial prompt run options" labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }); expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", { @@ -125,7 +122,6 @@ test("session create validates the requested mode against the provider's modes", labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }), ).rejects.toThrow("Invalid mode 'plan'"); @@ -169,7 +165,6 @@ test("session create applies the resolved mode from the provider create config", labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }); expect(createAgent).toHaveBeenCalledWith( @@ -247,7 +242,6 @@ test("session create stamps the requested workspaceId when no worktree setup run labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -258,41 +252,6 @@ test("session create stamps the requested workspaceId when no worktree setup run } }); -test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const agentManager = createRealAgentManager(storage); - - try { - const { snapshot } = await createAgentCommand( - { - agentManager, - agentStorage: storage, - logger, - providerSnapshotManager: createProviderSnapshotManagerStub().manager, - }, - { - kind: "session", - config: { provider: "codex", cwd: workdir }, - workspaceId: "ws-source", - labels: {}, - provisionalTitle: null, - firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ - sessionConfig: config, - setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} }, - createdWorkspaceId: "ws-new-worktree", - }), - }, - ); - - const stored = await storage.get(snapshot.id); - expect(stored?.workspaceId).toBe("ws-new-worktree"); - } finally { - await removeRealAgentManagerWorkdir({ agentManager, storage, workdir }); - } -}); - test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => { const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -309,7 +268,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -416,7 +374,6 @@ test("session create keeps the prompt title after the initial prompt settles", a labels: {}, provisionalTitle: title, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -454,7 +411,6 @@ test("session create keeps an explicit title after the initial prompt settles", labels: {}, provisionalTitle: title, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -492,7 +448,6 @@ test("session create with an internal config skips provider session persistence" labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }), }; await createAgentCommand(dependencies, { diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index b67935a0a8..f9fe10bd09 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -6,12 +6,10 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js"; import { expandUserPath, resolvePathFromBase } from "../../path-utils.js"; import { toWorktreeRequestError } from "../../worktree-errors.js"; import type { - AgentWorktreeSetupContinuation, - CreatePaseoWorktreeSetupContinuationInput, CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, } from "../../worktree-session.js"; -import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; +import type { AgentAttachment, FirstAgentContext } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; @@ -22,20 +20,8 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "../timeline-append.js"; import { resolveCreateAgentIntent } from "./intent.js"; -export interface CreateAgentSessionWorktreeResult { - sessionConfig: AgentSessionConfig; - setupContinuation?: AgentWorktreeSetupContinuation; - // Set when this build created a fresh worktree workspace. The agent must be - // stamped with it so workspaceId-scoped archive can find the agent later. - createdWorkspaceId?: string; -} - export interface CreateAgentCommandDependencies { agentManager: AgentManager; agentStorage: AgentStorage; @@ -61,24 +47,16 @@ export interface CreateAgentFromSessionInput { agentId?: string; config: AgentSessionConfig; workspaceId: string; - worktreeName?: string; initialPrompt?: string; source?: AgentPromptSource; clientMessageId?: string; outputSchema?: Record; images?: Array<{ data: string; mimeType: string }>; attachments?: AgentAttachment[]; - git?: GitSetupOptions; labels: Record; env?: Record; provisionalTitle: string | null; firstAgentContext: FirstAgentContext; - buildSessionConfig: ( - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, - ) => Promise; } export interface CreateAgentFromMcpInput { @@ -111,6 +89,7 @@ export interface CreateAgentFromMcpInput { allowCustomCwd?: boolean; childAgentDefaultLabels?: Record; } | null; + worktree?: { worktreeName?: string; branchName?: string; @@ -167,7 +146,6 @@ interface ResolvedCreateAgent { prompt?: AgentPromptInput; source?: AgentPromptSource; runOptions?: AgentRunOptions; - setupContinuation?: AgentWorktreeSetupContinuation; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -200,7 +178,7 @@ export async function createAgentCommand( input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && - (resolved.prompt !== undefined || resolved.setupContinuation !== undefined) + resolved.prompt !== undefined ? setupFinishNotification({ agentManager: dependencies.agentManager, agentStorage: dependencies.agentStorage, @@ -212,9 +190,6 @@ export async function createAgentCommand( }) : undefined; - resolved.setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); if (resolved.prompt !== undefined) { const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { stopNotification?.(); @@ -239,29 +214,7 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const { - sessionConfig: builtSessionConfig, - setupContinuation, - createdWorkspaceId, - } = await input.buildSessionConfig( - input.config, - input.git, - input.worktreeName, - input.firstAgentContext, - ); - // Validate the requested mode against the provider's modes for the resolved - // cwd. The app remembers mode preferences globally, so a saved mode can be - // stale for a workspace whose provider config no longer defines it — reject - // it here instead of letting the provider fail mid-turn. - // - // This runs after buildSessionConfig, which may already have created a - // worktree and/or workspace record — cwd (required to resolve modes) is - // only known once that step completes. If validation throws, any - // worktree/workspace buildSessionConfig created is the caller's - // responsibility to clean up (session.ts's handleCreateAgentRequest does - // this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate; - // this is a pre-existing gap for directory-only workspace creates, not - // introduced by this validation). + const builtSessionConfig = input.config; const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: builtSessionConfig.cwd, provider: builtSessionConfig.provider, @@ -285,7 +238,7 @@ async function resolveSessionCreateAgent( ...(clientMessageId ? { clientMessageId } : {}), } : undefined; - const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId; + const workspaceId = input.workspaceId; return { config: sessionConfig, @@ -297,15 +250,11 @@ async function resolveSessionCreateAgent( // Paseo never persists an internal agent, so nothing could resume its // provider session either. Same as the daemon's own ephemeral helpers. ...(sessionConfig.internal ? { persistSession: false } : {}), - // A legacy git/worktreeName worktree creates a fresh workspace, so the - // agent belongs to that workspace, not the source one. createdWorkspaceId - // is the freshly created worktree's workspace. workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, source: input.source, runOptions, - setupContinuation, // Registration commits creation. First-turn failures belong to the created agent. promptFailure: "return-error", promptLogger: dependencies.logger.child({ @@ -324,18 +273,16 @@ async function resolveMcpCreateAgent( ? requireParentAgent(dependencies.agentManager, input.callerAgentId) : null; const cwd = resolveMcpInitialCwd(input, parentAgent); - const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } = - await resolveMcpCwd({ - dependencies, - cwd, - worktree: input.worktree, - initialPrompt: input.initialPrompt ?? "", - callerWorkspaceId: parentAgent?.workspaceId, - }); + const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({ + dependencies, + cwd, + worktree: input.worktree, + initialPrompt: input.initialPrompt ?? "", + callerWorkspaceId: parentAgent?.workspaceId, + }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); - const intent = await resolveCreateAgentIntent({ - explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, + explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId, caller: parentAgent ? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId } : null, @@ -384,7 +331,6 @@ async function resolveMcpCreateAgent( source: input.callerAgentId ? { kind: "agent-message", agentId: input.callerAgentId } : undefined, - setupContinuation, createdWorktree, promptFailure: input.promptFailure ?? "log", }; @@ -536,6 +482,28 @@ function resolveChildAgentCwd(params: { return resolvePathFromBase(params.parentCwd, requestedCwd); } +interface CreateMcpWorktreeOptions { + input: CreatePaseoWorktreeInput; + createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; + resolveDefaultBranch?: (repoRoot: string) => Promise; +} + +async function createMcpWorktree( + options: CreateMcpWorktreeOptions, +): Promise { + try { + if (!options.createPaseoWorktree) { + throw new Error("Paseo worktree service is not configured"); + } + return await options.createPaseoWorktree( + options.input, + options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {}, + ); + } catch (error) { + throw toWorktreeRequestError(error); + } +} + async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; @@ -544,7 +512,6 @@ async function resolveMcpCwd(params: { worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; - setupContinuation?: AgentWorktreeSetupContinuation; createdWorkspaceId?: string; createdWorktree?: CreatePaseoWorktreeWorkflowResult; }> { @@ -578,59 +545,16 @@ async function resolveMcpCwd(params: { action: worktree.action, githubPrNumber: worktree.githubPrNumber, firstAgentContext: { prompt: params.initialPrompt }, - runSetup: false, + paseoHome: dependencies.paseoHome, worktreesRoot: dependencies.worktreesRoot, }, createPaseoWorktree: dependencies.createPaseoWorktree, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - setupContinuation: { - kind: "agent", - terminalManager: dependencies.terminalManager ?? null, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager: dependencies.agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: dependencies.agentManager, - agentId, - item, - }), - logger: dependencies.logger, - }, }); return { resolvedCwd: createdWorktree.workspace.cwd, - setupContinuation: createdWorktree.setupContinuation, createdWorkspaceId: createdWorktree.workspace.workspaceId, createdWorktree, }; } - -interface CreateMcpWorktreeOptions { - input: CreatePaseoWorktreeInput; - createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; - resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; -} - -async function createMcpWorktree( - options: CreateMcpWorktreeOptions, -): Promise { - try { - if (!options.createPaseoWorktree) { - throw new Error("Paseo worktree service is not configured"); - } - return await options.createPaseoWorktree(options.input, { - ...(options.resolveDefaultBranch - ? { resolveDefaultBranch: options.resolveDefaultBranch } - : {}), - ...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}), - }); - } catch (error) { - throw toWorktreeRequestError(error); - } -} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 6e093a18a4..e774820dee 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -72,7 +72,6 @@ import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol import type { DaemonConfigStore } from "../daemon-config-store.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; -import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; const REPO_CWD = resolvePath("/tmp/repo"); @@ -180,20 +179,6 @@ async function waitForWorkspaceTitle( }); } -async function waitForWorkspaceBranch( - workspaceRecords: Map, - workspaceId: string, - branch: string, -): Promise { - await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), { - timeout: 5_000, - }); -} - -async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise { - await new Promise((resolve) => setTimeout(resolve, 25)); -} - async function removeTempDir(path: string): Promise { await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } @@ -693,7 +678,6 @@ function createPaseoWorktreeForMcpTest(options: { createdWorkspaceIds?: string[]; workspaceRecords?: Map; generateWorkspaceName?: () => Promise; - setupContinuations?: Array<"workspace" | "agent" | undefined>; startedAgentSetupIds?: string[]; }): CreatePaseoWorktreeWorkflowFn { const projects = new Map(); @@ -790,7 +774,6 @@ function createPaseoWorktreeForMcpTest(options: { }); return async (input, serviceOptions) => { - options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); const result = await createPaseoWorktreeWorkflow( { paseoHome: options.paseoHome, @@ -824,17 +807,6 @@ 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; }; } @@ -1466,16 +1438,6 @@ describe("create_agent MCP tool", () => { relationship: { kind: "detached" as const }, workspace: { kind: "create" as const, source: { kind: "directory" as const, path } }, }); - const detachedWorktreeWorkspace = ( - cwd: string, - target: - | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } - | { kind: "checkout-branch"; branch: string } - | { kind: "checkout-pr"; githubPrNumber: number }, - ) => ({ - relationship: { kind: "detached" as const }, - workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } }, - }); const subagentCurrentWorkspace = (cwd?: string) => ({ relationship: { kind: "subagent" as const }, workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) }, @@ -1902,29 +1864,6 @@ describe("create_agent MCP tool", () => { ).rejects.toThrow("Unrecognized key"); }); - it("accepts worktree workspace intent in create_agent input validation", async () => { - const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "create_agent"); - - const parsed = await tool.inputSchema.safeParseAsync({ - ...detachedWorktreeWorkspace(existingCwd, { - kind: "checkout-pr", - githubPrNumber: 42, - }), - title: "Short title", - provider: "codex/gpt-5.4", - initialPrompt: "test", - }); - - expect(parsed.success).toBe(true); - }); - it("exposes workspace tools instead of worktree tools", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -2082,444 +2021,6 @@ describe("create_agent MCP tool", () => { ); }); - it("registers and broadcasts a workspace when create_agent creates a worktree", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const setupContinuations: Array<"workspace" | "agent" | undefined> = []; - const startedAgentSetupIds: string[] = []; - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-with-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Worktree agent" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - setupContinuations, - startedAgentSetupIds, - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - worktreeSlug: "agent-worktree", - branchName: "feature/agent-worktree", - baseBranch: "main", - }), - title: "Worktree agent", - provider: "codex/gpt-5.4", - initialPrompt: "Do work", - }); - - expect(broadcasts).toHaveLength(1); - expect(createdWorkspaceIds).toHaveLength(1); - expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); - expect(setupContinuations).toEqual(["agent"]); - expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]); - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branchName).toBe("feature/agent-worktree"); - // The agent is stamped with the freshly created worktree's workspaceId so - // workspaceId-scoped archive can find and tear it down later. - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining("agent-worktree"), - }), - undefined, - { workspaceId: createdWorkspaceIds[0] }, - ); - } finally { - await removeTempDir(tempDir); - } - }); - - it("creates a create_agent branch-off worktree without invoking the legacy 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 { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("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, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - baseBranch: "main", - }), - title: "Worktree agent", - provider: "codex/gpt-5.4", - initialPrompt: "Fix workspace creation naming", - }); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const initialBranch = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(initialBranch).not.toBe(""); - expect(initialBranch).not.toBe("main"); - await waitForUnexpectedWorkspaceNamingSideEffects(); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(broadcasts).toHaveLength(1); - } finally { - await removeTempDir(tempDir); - } - }); - - it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-auto-titled-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Agent title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Workspace Auto Title Flow", - branch: "workspace-auto-title-flow", - }), - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/placeholder-auto-title", - baseBranch: "main", - }), - title: "Agent title", - provider: "codex/gpt-5.4", - initialPrompt: "Build a workspace auto title flow", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspace = workspaceRecords.get(workspaceId); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - const metadata = readPaseoWorktreeMetadata(agentCwd); - - expect(metadata).toMatchObject({ - version: 2, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: "feat/placeholder-auto-title", - }, - }); - expect(branchName).toBe("workspace-auto-title-flow"); - expect(workspace).toMatchObject({ - title: "Workspace Auto Title Flow", - branch: "workspace-auto-title-flow", - }); - } finally { - await removeTempDir(tempDir); - } - }); - - it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-manual-title-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Agent title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Generated Manual Race Title", - branch: "generated-manual-race-title", - }), - }), - workspaceRegistry: { - get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null, - upsert: async (record) => { - workspaceRecords.set(record.workspaceId, record); - }, - }, - emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { - broadcasts.push(...workspaceIds); - }, - logger, - }); - const createAgentTool = registeredTool(server, "create_agent"); - await createAgentTool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/manual-title-placeholder", - baseBranch: "main", - }), - title: "Agent title", - provider: "codex/gpt-5.4", - initialPrompt: "Keep the manually renamed workspace title", - }); - const renameWorkspaceTool = registeredTool(server, "rename_workspace"); - await renameWorkspaceTool.handler({ - workspaceId: createdWorkspaceIds[0], - title: "Manual Workspace Title", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title"); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspace = workspaceRecords.get(workspaceId); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - - expect(branchName).toBe("generated-manual-race-title"); - expect(workspace).toMatchObject({ - title: "Manual Workspace Title", - branch: "generated-manual-race-title", - }); - } finally { - await removeTempDir(tempDir); - } - }); - - it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-explicit-title-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Explicit Agent Title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Generated Workspace Title", - branch: "generated-workspace-title", - }), - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/agent-title-placeholder", - baseBranch: "main", - }), - title: "Explicit Agent Title", - provider: "codex/gpt-5.4", - initialPrompt: "Generate the workspace title anyway", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); - - const workspace = workspaceRecords.get(workspaceId); - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Explicit Agent Title", - }), - undefined, - { workspaceId }, - ); - expect(workspace).toMatchObject({ - title: "Generated Workspace Title", - branch: "generated-workspace-title", - }); - } finally { - await removeTempDir(tempDir); - } - }); - it("auto-titles an agent-created directory workspace from the initial prompt", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-")); @@ -2630,221 +2131,12 @@ describe("create_agent MCP tool", () => { } }); - it("auto-titles without renaming 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 createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - let generateCalls = 0; - const workspaceGitService = { - getSnapshot: vi.fn(async () => { - throw new Error("agent metadata branch rename should not run"); - }), - }; - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["checkout", "-b", "existing-feature"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "feature.txt"), "feature\n"); - execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("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, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => { - generateCalls += 1; - return { - title: "Generated Checkout Workspace Title", - branch: "generated-checkout-workspace-title", - }; - }, - }), - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "checkout-branch", - branch: "existing-feature", - }), - title: "Checkout agent", - provider: "codex/gpt-5.4", - initialPrompt: "Rename this checkout from the prompt", - }); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle( - workspaceRecords, - workspaceId, - "Generated Checkout Workspace Title", - ); - expect( - execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) - .toString() - .trim(), - ).toBe("existing-feature"); - expect(workspaceRecords.get(workspaceId)).toMatchObject({ - title: "Generated Checkout Workspace Title", - branch: "existing-feature", - }); - expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ - version: 1, - baseRefName: "main", - }); - expect(generateCalls).toBe(1); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(broadcasts).toEqual([workspaceId, workspaceId]); - } finally { - await removeTempDir(tempDir); - } - }); - - 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: "ws-pr-123", - projectId: REPO_CWD, - 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: REPO_CWD, - 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, - providerSnapshotManager: createOpenCodeManager().manager, - createPaseoWorktree, - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(REPO_CWD, { - kind: "checkout-pr", - githubPrNumber: 123, - }), - title: "PR agent", - provider: "codex/gpt-5.4", - initialPrompt: "Rename this PR branch from prompt", - }); - - expect(createPaseoWorktree).toHaveBeenCalledWith( - expect.objectContaining({ - githubPrNumber: 123, - firstAgentContext: { prompt: "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, - { workspaceId: "ws-pr-123" }, - ); - await waitForUnexpectedWorkspaceNamingSideEffects(); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - }); - it("creates a worktree-isolated workspace", 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 { execFileSync("git", ["init", repoDir], { stdio: "pipe" }); @@ -2875,7 +2167,6 @@ describe("create_agent MCP tool", () => { createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts, - setupContinuations, }), workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, @@ -2896,7 +2187,6 @@ describe("create_agent MCP tool", () => { expect(response.structuredContent.cwd).toContain("tool-worktree"); expect(response.structuredContent.workspaceId).toBe(broadcasts[0]); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(setupContinuations).toEqual([undefined]); expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); } finally { @@ -7378,3 +6668,40 @@ describe("agent snapshot MCP serialization", () => { expect(content).not.toContain("first answer"); }); }); + +it("rejects hidden agent-worktree placement and directs callers to workspace creation", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger: createTestLogger(), + }); + const tool = registeredTool(server, "create_agent"); + await expect( + tool.handler({ + provider: "codex/gpt-5.4", + title: "Worktree agent", + initialPrompt: "test", + relationship: { kind: "detached" }, + workspace: { + kind: "create", + source: { + kind: "worktree", + cwd: process.cwd(), + target: { kind: "branch-off", worktreeSlug: "legacy" }, + }, + }, + }), + ).rejects.toThrow("create_workspace"); + await expect( + tool.handler({ + provider: "codex/gpt-5.4", + title: "Worktree agent", + initialPrompt: "test", + cwd: process.cwd(), + worktreeName: "legacy", + baseBranch: "main", + }), + ).rejects.toThrow("create_workspace"); +}); diff --git a/packages/server/src/server/agent/timeline-append.ts b/packages/server/src/server/agent/timeline-append.ts deleted file mode 100644 index e97fce65c6..0000000000 --- a/packages/server/src/server/agent/timeline-append.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentManager } from "./agent-manager.js"; -import type { AgentTimelineItem } from "./agent-sdk-types.js"; - -export interface AppendTimelineItemIfAgentKnownOptions { - agentManager: AgentManager; - agentId: string; - item: AgentTimelineItem; -} - -export async function appendTimelineItemIfAgentKnown( - options: AppendTimelineItemIfAgentKnownOptions, -): Promise { - try { - await options.agentManager.appendTimelineItem(options.agentId, options.item); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Unknown agent")) { - return false; - } - throw error; - } -} - -export async function emitLiveTimelineItemIfAgentKnown( - options: AppendTimelineItemIfAgentKnownOptions, -): Promise { - try { - await options.agentManager.emitLiveTimelineItem(options.agentId, options.item); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Unknown agent")) { - return false; - } - throw error; - } -} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 30f28426b1..a5117b36b1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -33,7 +33,7 @@ import { requireActiveWorkspaceForArchive, type ArchiveDependencies, } from "../../workspace-archive-service.js"; -import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; +import { createAgentCommand } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; @@ -1030,43 +1030,6 @@ function createToolCatalog( .strict() .describe("Create a root agent that does not appear in this agent's subagent track."), ]); - const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [ - z - .object({ - kind: z.literal("branch-off"), - worktreeSlug: z - .string() - .min(1) - .optional() - .describe("Optional worktree slug/path label. Omit to let Paseo generate one."), - branchName: z - .string() - .min(1) - .optional() - .describe("Optional git branch name. Defaults to the worktree slug."), - baseBranch: z - .string() - .min(1) - .optional() - .describe("Optional base branch. Defaults to the repository default branch."), - }) - .strict() - .describe("Create a new branch in a new Paseo worktree."), - z - .object({ - kind: z.literal("checkout-branch"), - branch: z.string().min(1).describe("Existing branch to check out."), - }) - .strict() - .describe("Check out an existing branch in a new Paseo worktree."), - z - .object({ - kind: z.literal("checkout-pr"), - githubPrNumber: z.number().int().positive().describe("GitHub pull request number."), - }) - .strict() - .describe("Check out a GitHub pull request in a new Paseo worktree."), - ]); const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [ z .object({ @@ -1099,16 +1062,6 @@ function createToolCatalog( .describe("Optional directory path. Defaults to the caller's cwd."), }) .strict(), - z - .object({ - kind: z.literal("worktree"), - cwd: z - .string() - .optional() - .describe("Optional source repository. Defaults to the caller's cwd."), - target: AgentCreateWorktreeTargetInputSchema, - }) - .strict(), ]), }) .strict() @@ -1195,32 +1148,6 @@ function createToolCatalog( .record(z.string(), z.unknown()) .optional() .describe("Legacy feature values. Prefer settings.features."), - worktreeName: z - .string() - .min(1) - .optional() - .describe("Legacy worktree slug. Prefer workspace.source.target.worktreeSlug."), - branchName: z - .string() - .min(1) - .optional() - .describe("Legacy branch name. Prefer workspace.source.target.branchName."), - baseBranch: z - .string() - .min(1) - .optional() - .describe("Legacy base branch. Prefer workspace.source.target.baseBranch."), - refName: z - .string() - .min(1) - .optional() - .describe("Legacy branch/ref to check out. Prefer workspace.source.target.branch."), - githubPrNumber: z - .number() - .int() - .positive() - .optional() - .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; const createAgentInputSchema = z .object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema) @@ -1560,7 +1487,7 @@ function createToolCatalog( }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); - const { parsedArgs, worktree } = resolvedArgs; + const { parsedArgs } = resolvedArgs; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { notifyOnFinish = parsedArgs.notifyOnFinish; @@ -1600,7 +1527,6 @@ function createToolCatalog( detached: resolvedArgs.detached, callerAgentId, callerContext, - worktree, }, ); @@ -1634,7 +1560,6 @@ function createToolCatalog( detached: boolean; cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; } | { kind: "top-level"; @@ -1642,16 +1567,38 @@ function createToolCatalog( detached: boolean; cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; }; async function resolveCreateAgentToolArgs(args: unknown): Promise { + const placement = args as { + worktreeName?: unknown; + branchName?: unknown; + baseBranch?: unknown; + refName?: unknown; + githubPrNumber?: unknown; + workspace?: { source?: { kind?: unknown } }; + }; + if ( + placement && + (placement.workspace?.source?.kind === "worktree" || + [ + placement.worktreeName, + placement.branchName, + placement.baseBranch, + placement.refName, + placement.githubPrNumber, + ].some((value) => value !== undefined)) + ) { + throw new PaseoToolInputError( + "Agent worktree placement was removed. Use create_workspace first, then create_agent with workspaceId.", + ); + } if (callerAgentId) { if (hasLegacyCreateAgentPlacement(args)) { // COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without // advertising it to models. Added in v0.2.0; remove after 2027-01-17. const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsed.workspace, { prompt: parsed.initialPrompt, }); return { @@ -1660,7 +1607,6 @@ function createToolCatalog( detached: parsed.relationship.kind === "detached", cwd, workspaceId, - worktree, }; } const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args); @@ -1673,7 +1619,6 @@ function createToolCatalog( detached: false, cwd, workspaceId, - worktree: undefined, }; } if (hasLegacyCreateAgentPlacement(args)) { @@ -1689,17 +1634,15 @@ function createToolCatalog( if (!parsedArgs.workspace) { throw new PaseoToolInputError("Legacy create_agent placement could not be resolved"); } - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace( - parsedArgs.workspace, - { prompt: parsedArgs.initialPrompt }, - ); + const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsedArgs.workspace, { + prompt: parsedArgs.initialPrompt, + }); return { kind: "top-level", parsedArgs, detached: true, cwd, workspaceId, - worktree, }; } const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args); @@ -1713,7 +1656,6 @@ function createToolCatalog( detached: false, cwd, workspaceId, - worktree: undefined, }; } @@ -1770,18 +1712,7 @@ function createToolCatalog( function normalizeTopLevelCreateAgentArgs( args: LegacyTopLevelCreateAgentArgs, ): LegacyTopLevelCreateAgentArgs { - const { - cwd, - mode, - thinking, - features, - worktreeName, - branchName, - baseBranch, - refName, - githubPrNumber, - ...canonicalCandidate - } = args; + const { cwd, mode, thinking, features, ...canonicalCandidate } = args; const settings = { ...canonicalCandidate.settings, ...(mode ? { modeId: mode } : {}), @@ -1804,29 +1735,10 @@ function createToolCatalog( throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls"); } - const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({ - worktreeName, - branchName, - baseBranch, - refName, - githubPrNumber, - }); - const workspace = legacyWorktreeTarget - ? { - kind: "create" as const, - source: { - kind: "worktree" as const, - cwd, - target: legacyWorktreeTarget, - }, - } - : { - kind: "create" as const, - source: { - kind: "directory" as const, - path: cwd, - }, - }; + const workspace = { + kind: "create" as const, + source: { kind: "directory" as const, path: cwd }, + }; return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, { ...canonicalCandidate, @@ -1836,39 +1748,6 @@ function createToolCatalog( }); } - function resolveLegacyCreateAgentWorktreeTarget(input: { - worktreeName?: string; - branchName?: string; - baseBranch?: string; - refName?: string; - githubPrNumber?: number; - }): z.infer | null { - if (input.githubPrNumber !== undefined) { - return { - kind: "checkout-pr", - githubPrNumber: input.githubPrNumber, - }; - } - - if (input.refName) { - return { - kind: "checkout-branch", - branch: input.refName, - }; - } - - if (input.worktreeName || input.branchName || input.baseBranch) { - return { - kind: "branch-off", - worktreeSlug: input.worktreeName, - branchName: input.branchName, - baseBranch: input.baseBranch, - }; - } - - return null; - } - async function resolveCreateAgentWorkspace( workspace: | LegacyAgentToAgentCreateAgentArgs["workspace"] @@ -1877,7 +1756,6 @@ function createToolCatalog( ): Promise<{ cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; }> { if (workspace.kind === "current") { if (!callerAgentId) { @@ -1890,7 +1768,6 @@ function createToolCatalog( return { cwd: workspace.cwd, workspaceId: callerAgent.workspaceId, - worktree: undefined, }; } @@ -1916,7 +1793,6 @@ function createToolCatalog( return { cwd, workspaceId: workspace.workspaceId, - worktree: undefined, }; } @@ -1930,42 +1806,12 @@ function createToolCatalog( workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { callerWorkspaceId: resolveCallerAgent()?.workspaceId, }), - worktree: undefined, }; } - const cwd = resolveScopedCwd(workspace.source.cwd, { required: true }); - return { - cwd, - workspaceId: undefined, - worktree: resolveCreateAgentWorktree(workspace.source.target), - }; - } - - function resolveCreateAgentWorktree( - target: z.infer, - ): NonNullable { - switch (target.kind) { - case "branch-off": - return { - action: "branch-off", - worktreeName: target.worktreeSlug, - branchName: target.branchName, - baseBranch: target.baseBranch, - }; - case "checkout-branch": - return { - action: "checkout", - refName: target.branch, - }; - case "checkout-pr": - return { - action: "checkout", - githubPrNumber: target.githubPrNumber, - }; - default: - throw new Error("unreachable"); - } + throw new PaseoToolInputError( + "Use create_workspace first, then create_agent with workspaceId.", + ); } const PROMPTED_AGENT_NOTIFICATION_GUIDANCE = diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index cc0bf08250..f050237620 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -200,7 +200,7 @@ async function createPaseoOwnedWorktree( baseBranch: "main", branchName: worktreeSlug, }, - runSetup: false, + paseoHome, }); } diff --git a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts index c0e20e36f2..218ae4aee3 100644 --- a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts @@ -48,7 +48,7 @@ async function createWorkspaceJourney() { cwd: repoDir, worktreeSlug: "workspace", source: { kind: "branch-off", baseBranch: "main", branchName: "workspace" }, - runSetup: false, + paseoHome, }); const workspaceId = "workspace-under-test"; diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts index f4d35bd3f3..48cecaa4ac 100644 --- a/packages/server/src/server/creation/creation.e2e.test.ts +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -4,7 +4,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { WebSocket } from "ws"; import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; @@ -395,3 +395,181 @@ test.each(["agent", "workspace"] as const)( }, 60000, ); + +test("combined worktree and agent creation owns setup on the workspace and replays one identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "workspace-owned-setup-")); + const daemon = await createCreationDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await initializeSetupRepository(directory); + await client.connect(); + const input = { + idempotencyKey: "combined-workspace-setup", + source: { kind: "worktree" as const, cwd: directory, worktreeSlug: "combined-setup" }, + agent: { config: { provider: "codex", cwd: directory } }, + }; + const first = await client.createWorkspace(input); + const replay = await client.createWorkspace(input); + expect(first.error).toBeNull(); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(first.workspace?.id); + expect(replay.agent?.id).toBe(first.agent?.id); + if (!first.workspace || !first.agent) throw new Error("Expected combined creation resources"); + const observation = client.observeWorkspaceSetup(first.workspace.id); + await observation.ready; + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(first.workspace!.id)).snapshot, { + timeout: 10000, + }) + .toMatchObject({ + status: "completed", + detail: { + log: "", + commands: [ + expect.objectContaining({ + log: expect.stringContaining("workspace setup output\n"), + status: "completed", + }), + ], + }, + }); + await observation.release(); + expect(await readFile(join(first.workspace.workspaceDirectory!, "setup-runs"), "utf8")).toBe( + "once\n", + ); + const timeline = await client.fetchAgentTimeline(first.agent.id); + expect( + timeline.entries.map((entry) => entry.item).filter((item) => item.type === "tool_call"), + ).toEqual([]); + expect(first.agent.workspaceId).toBe(first.workspace.id); + const conflict = await client.createWorkspace({ + ...input, + source: { ...input.source, worktreeSlug: "different-intent" }, + }); + expect(conflict.error).toBe("workspace_request_key_conflict"); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "%s rejects agent worktree placement at the wire boundary", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "agent-worktree-rejected-")); + const daemon = await createCreationDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + for (const placement of [{ git: { createWorktree: true } }, { worktreeName: "legacy" }]) { + const response = await peer.request({ + type, + requestId: randomUUID(), + config: { provider: "codex", cwd: directory }, + ...placement, + }); + if (response.type !== "agent.create.response" && response.type !== "status") + throw new Error("Expected agent creation response"); + const error = response.payload.error; + expect(error).toContain("createWorkspace"); + } + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +async function initializeSetupRepository(directory: string): Promise { + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + await writeFile( + join(directory, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + "node -e \"require('fs').appendFileSync('setup-runs', 'once\\n'); console.log('workspace setup output')\"", + ], + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: directory }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "setup fixture", + ], + { cwd: directory, stdio: "pipe" }, + ); +} + +test("Hub-shaped keyed agent worktree creation uses workspace setup and preserves resource identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "keyed-agent-workspace-setup-")); + const daemon = await createCreationDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const peer = await connectCreationPeer(daemon.port); + try { + await initializeSetupRepository(directory); + await client.connect(); + const input = { + type: "create_agent_request" as const, + idempotencyKey: "keyed-agent-worktree", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "branch-off" as const, newBranch: "keyed-agent-setup", base: "main" }, + }; + const first = await peer.request({ ...input, requestId: "first" }); + const replay = await peer.request({ ...input, requestId: "replay" }); + if ( + first.type !== "status" || + first.payload.status !== "agent_created" || + replay.type !== "status" || + replay.payload.status !== "agent_created" + ) + throw new Error("Expected agent creation responses"); + expect(replay.payload.agent.id).toBe(first.payload.agent.id); + expect(replay.payload.agent.workspaceId).toBe(first.payload.agent.workspaceId); + const agent = first.payload.agent; + if (!agent.workspaceId) throw new Error("Expected agent workspace ownership"); + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(agent.workspaceId!)).snapshot, { + timeout: 10000, + }) + .toMatchObject({ + status: "completed", + detail: { + log: "", + commands: [ + expect.objectContaining({ log: expect.stringContaining("workspace setup output\n") }), + ], + }, + }); + expect(await readFile(join(agent.cwd, "setup-runs"), "utf8")).toBe("once\n"); + expect( + (await client.fetchAgentTimeline(agent.id)).entries + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call"), + ).toEqual([]); + const conflict = await peer.request({ + ...input, + requestId: "conflict", + worktree: { ...input.worktree, newBranch: "different-intent" }, + }); + expect(conflict).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", error: "agent_request_key_conflict" }, + }); + } finally { + peer.close(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts index 67d39b3134..76ed83b3b2 100644 --- a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts @@ -106,14 +106,14 @@ githubTest( cwd: repository, worktreeSlug: "merged-feature", source: { kind: "branch-off", baseBranch: "main", branchName: "merged-feature" }, - runSetup: false, + paseoHome: context.daemon.paseoHome, }); const unrelated = await createWorktree({ cwd: repository, worktreeSlug: "never-pushed-unrelated", source: { kind: "branch-off", baseBranch: "main", branchName: "never-pushed-unrelated" }, - runSetup: false, + paseoHome: context.daemon.paseoHome, }); const mergedWorkspace = await context.client.createWorkspace({ diff --git a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts index d686783835..d7f09182f9 100644 --- a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts @@ -215,7 +215,7 @@ describe("daemon checkout PR merge loop", () => { baseBranch: "main", branchName: "merge-pr-squash", }, - runSetup: true, + paseoHome: ctx.daemon.paseoHome, }); worktreePath = worktree.worktreePath; diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 9fdc90635d..a9becb6473 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -20,7 +20,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -39,7 +39,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index be225f171d..2c49f7f79f 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -12,74 +12,14 @@ import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; -import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import { isPlatform } from "../../test-utils/platform.js"; -import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import type { SessionOutboundMessage } from "../messages.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -function findTimelineToolCall( - messages: SessionOutboundMessage[], - agentId: string, - predicate: (item: AgentTimelineItem) => boolean, -): AgentTimelineItem | null { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const msg = messages[i]; - if (msg?.type !== "agent_stream") { - continue; - } - if (msg.payload.agentId !== agentId) { - continue; - } - const event = msg.payload.event as { type?: string; item?: AgentTimelineItem }; - if (event?.type !== "timeline") { - continue; - } - const item = event.item as AgentTimelineItem; - if (item?.type === "tool_call" && predicate(item)) { - return item; - } - } - return null; -} - -async function waitForTimelineToolCall( - messages: SessionOutboundMessage[], - agentId: string, - predicate: (item: AgentTimelineItem) => boolean, - timeoutMs = 10000, -): Promise> { - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - const existing = findTimelineToolCall(messages, agentId, predicate); - if (existing && existing.type === "tool_call") { - return existing; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - const recentToolCalls: Array<{ name: string; status?: string; callId?: string }> = []; - for (let i = messages.length - 1; i >= 0 && recentToolCalls.length < 10; i -= 1) { - const msg = messages[i]; - if (msg?.type !== "agent_stream") continue; - if (msg.payload.agentId !== agentId) continue; - const event = msg.payload.event as { type?: string; item?: AgentTimelineItem }; - if (event?.type !== "timeline") continue; - const item = event.item as AgentTimelineItem; - if (item?.type !== "tool_call") continue; - recentToolCalls.push({ name: item.name, status: item.status, callId: item.callId }); - } - throw new Error( - `Timed out waiting for timeline tool_call (${agentId}). Recent tool_calls: ${JSON.stringify( - recentToolCalls, - )}`, - ); -} - async function waitForPathExists(options: { targetPath: string; timeoutMs: number; @@ -121,43 +61,17 @@ async function withShell(shell: string, run: () => Promise): Promise { } } -interface WorktreeTerminalBootstrapEntry { - name: string | null; - command: string; - status: "started" | "failed"; - terminalId: string | null; - error: string | null; -} - -function getWorktreeTerminalBootstrapEntries( - item: Extract, -): WorktreeTerminalBootstrapEntry[] | null { - const detail = item.detail; - if (!detail || detail.type !== "unknown" || !detail.output) { - return null; - } - const output = detail.output as Record; - const terminals = output.terminals; - if (!Array.isArray(terminals)) { - return null; - } - return terminals as WorktreeTerminalBootstrapEntry[]; -} - // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; let ctx: DaemonTestContext; -let collector: MessageCollector; beforeEach(async () => { ctx = await createDaemonTestContext(); - collector = createMessageCollector(ctx.client); }); afterEach(async () => { - collector.unsubscribe(); await ctx.cleanup(); }, 60000); @@ -423,7 +337,7 @@ test("returns isGit false for non-git directory", async () => { rmSync(cwd, { recursive: true, force: true }); }, 60000); // 1 minute timeout -test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => { +test("runs paseo.json setup asynchronously and reports workspace status", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -461,13 +375,7 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_ thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-test", - worktreeSlug: "async-setup-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -478,19 +386,11 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_ writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - const completed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); - - expect(completed.callId).toBeTruthy(); - expect(completed.detail.type).toBe("worktree_setup"); - if (completed.detail.type === "worktree_setup") { - expect(completed.detail.commands.length).toBeGreaterThan(0); - expect(completed.detail.log.length).toBeGreaterThan(0); - } + const completed = await waitForWorkspaceSetup(agent.workspaceId!, "completed"); + expect(completed.detail.log).toBe(""); + expect(completed.detail.commands).toEqual([ + expect.objectContaining({ status: "completed", exitCode: 0 }), + ]); expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); await ctx.client.deleteAgent(agent.id); @@ -549,13 +449,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup + Terminals Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-terminals-test", - worktreeSlug: "async-setup-terminals-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-terminals-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -568,25 +462,12 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); - const terminalsBootstrapToolCall = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", - 30000, - ); - const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall); - expect(bootstrappedTerminals).toBeTruthy(); - expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); - const failedBootstraps = - bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; - expect(failedBootstraps).toEqual([]); - + await waitForWorkspaceSetup(agent.workspaceId!, "completed"); + await expect + .poll(async () => (await ctx.client.listTerminals(agent.cwd)).terminals.length, { + timeout: 30000, + }) + .toBe(2); const list = await ctx.client.listTerminals(agent.cwd); expect(list.error).toBeUndefined(); expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); @@ -639,7 +520,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = }); }, 60000); -test("reports failures via timeline tool_call without deleting the created worktree", async () => { +test("reports workspace setup failures without deleting the created worktree", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -687,13 +568,7 @@ test("reports failures via timeline tool_call without deleting the created workt thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Failure Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-failure-test", - worktreeSlug: "async-setup-failure-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-failure-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on failing setup", @@ -702,23 +577,7 @@ test("reports failures via timeline tool_call without deleting the created workt expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); expect(existsSync(agent.cwd)).toBe(true); - const started = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "running", - 10000, - ); - - const failed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => - item.name === "paseo_worktree_setup" && - item.callId === started.callId && - item.status === "failed", - 20000, - ); - + const failed = await waitForWorkspaceSetup(agent.workspaceId!, "failed"); expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); @@ -726,7 +585,8 @@ test("reports failures via timeline tool_call without deleting the created workt if (failed.detail.type === "worktree_setup") { expect(Array.isArray(failed.detail.commands)).toBe(true); expect(failed.detail.commands[0]?.exitCode).toBe(7); - expect(failed.detail.log).toContain("Exit 7"); + expect(failed.detail.log).toBe(""); + expect(failed.detail.commands[0]?.log).toContain("boom"); } await ctx.client.deleteAgent(agent.id); @@ -756,13 +616,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Worktree Agent Test", - git: { - createWorktree: true, - createNewBranch: true, - newBranchName: "worktree-test", - worktreeSlug: "worktree-test", - baseBranch: "main", - }, + worktree: { mode: "branch-off", newBranch: "worktree-test", base: "main" }, }); expect(agent.id).toBeTruthy(); @@ -776,7 +630,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as rmSync(cwd, { recursive: true, force: true }); }, 60000); -test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => { +test("archiving a worktree shuts down terminals and removes its final workspace backing", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -823,13 +677,7 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Worktree Archive Cleanup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "archive-cleanup-test", - worktreeSlug: "archive-cleanup-test", - }, + worktree: { mode: "branch-off", newBranch: "archive-cleanup-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -858,14 +706,23 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d expect(archive.success).toBe(true); expect(archive.removedAgents).toContain(agent.id); - // Archiving tears down the workspace's terminals but never deletes the - // worktree from disk and never runs teardown commands — on-disk removal is a - // separate, explicit step. - expect(existsSync(agent.cwd)).toBe(true); - expect(existsSync(teardownMarkerPath)).toBe(false); + // The final workspace reference owns teardown and backing-worktree removal. + expect(existsSync(agent.cwd)).toBe(false); + expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); expect(afterArchiveDirectories).not.toContain(agent.cwd); rmSync(repoRoot, { recursive: true, force: true }); }, 60000); + +async function waitForWorkspaceSetup(workspaceId: string, status: "completed" | "failed") { + await expect + .poll(async () => (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot?.status, { + timeout: 20000, + }) + .toBe(status); + const snapshot = (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot; + if (!snapshot) throw new Error("Expected workspace setup snapshot"); + return snapshot; +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index a5120aa5ff..0e0ccf34f7 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -59,7 +59,7 @@ test("creates a worktree and registers it in the source workspace project withou cwd: repoDir, worktreeSlug: "feature-one", title: "Feature One", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -114,7 +114,7 @@ test("refreshes a source project that became Git while creating a worktree", asy { cwd: repoDir, worktreeSlug: "project-became-git", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -148,7 +148,7 @@ test("repairs a legacy source workspace whose project record is missing", async { cwd: repoDir, worktreeSlug: "repaired-source", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -192,7 +192,7 @@ test("uses an equivalent source workspace path when creating a worktree", async { cwd: sourceDir, worktreeSlug: "equivalent-source", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -221,7 +221,7 @@ test("creates a worktree workspace at the selected project subdirectory", async cwd: sourceDir, projectId: project.projectId, worktreeSlug: "selected-subdirectory", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -249,7 +249,7 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo { cwd: sourceDir, worktreeSlug: "seed-nested-config", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -277,7 +277,7 @@ test("does not overwrite a committed exact-project config with source checkout e { cwd: sourceDir, worktreeSlug: "preserve-nested-config", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -314,7 +314,7 @@ test("removes a new worktree when its ref does not contain the selected project action: "checkout", refName: "without-subproject", worktreeSlug: "missing-subproject", - runSetup: false, + paseoHome, }, deps, @@ -345,7 +345,7 @@ test("removes a new worktree when workspace persistence fails", async () => { cwd: repoDir, projectId: "missing-project", worktreeSlug: "persistence-failure", - runSetup: false, + paseoHome, }, createDeps(), @@ -373,7 +373,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree", { cwd: repoDir, worktreeSlug: "source-worktree", - runSetup: false, + paseoHome, }, deps, @@ -384,7 +384,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree", { cwd: sourceCwd, worktreeSlug: "nested-worktree", - runSetup: false, + paseoHome, }, deps, @@ -408,7 +408,7 @@ test("rejects source checkout planning before creating a worktree", async () => { cwd: repoDir, worktreeSlug: "must-not-create", - runSetup: false, + paseoHome, }, deps, @@ -443,7 +443,7 @@ test("registers a new worktree in the existing root project after the main check cwd: repoDir, projectId: sourceProject.projectId, worktreeSlug: "second-worktree", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -472,7 +472,7 @@ test("an explicit project FK remains unchanged when its worktree comes from anot cwd: repoDir, projectId: project.projectId, worktreeSlug: "attached-worktree", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -502,7 +502,7 @@ test.skipIf(isPlatform("win32"))( { cwd: repoDir, worktreeSlug: "reuse-me", - runSetup: false, + paseoHome, }, firstDeps, @@ -518,7 +518,7 @@ test.skipIf(isPlatform("win32"))( { cwd: repoDir, worktreeSlug: "reuse-me", - runSetup: false, + paseoHome, }, deps, @@ -541,7 +541,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex { cwd: repoDir, worktreeSlug: "dazzling-yak", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -610,7 +610,7 @@ test("falls back to a numeric suffix when the desired branch name already exists { cwd: repoDir, worktreeSlug: "dazzling-yak", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -647,7 +647,7 @@ test("renames the branch even when the app supplies a random placeholder slug", cwd: repoDir, worktreeSlug: "dazzling-yak", firstAgentContext: { prompt: "Investigate the failing login flow" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -695,7 +695,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied" }, ], }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -740,7 +740,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = cwd: repoDir, worktreeSlug: "dazzling-yak", firstAgentContext: { prompt: "Name this branch" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -785,7 +785,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename cwd: repoDir, action: "checkout", refName: "dev", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -825,7 +825,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren cwd: repoDir, action: "checkout", githubPrNumber: 123, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -862,7 +862,7 @@ test("does not mutate registries or broadcast when core worktree creation fails" { cwd: tempDir, worktreeSlug: "not-git", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -888,7 +888,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); expect(existsSync(created.worktreePath)).toBe(true); @@ -903,7 +903,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); @@ -947,7 +947,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); expect(existsSync(created.worktreePath)).toBe(true); @@ -970,7 +970,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }), ).rejects.toThrow("missing but already registered worktree"); @@ -982,7 +982,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); @@ -1010,7 +1010,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "gone-branch", source: { kind: "checkout-branch", branchName: "gone-branch" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }), ).rejects.toMatchObject({ name: "UnknownBranchError" }); @@ -1029,7 +1029,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "busy-branch", source: { kind: "checkout-branch", branchName: "busy-branch" }, - runSetup: false, + paseoHome, }); expect(existsSync(first.worktreePath)).toBe(true); @@ -1038,7 +1038,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "busy-branch-again", source: { kind: "checkout-branch", branchName: "busy-branch" }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 94e82d5e95..61e00723d0 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -135,7 +135,7 @@ async function createPaseoWorktreeWithPriority( { cwd: createdWorktree.repoRoot, worktreePath: createdWorktree.worktree.worktreePath, - ...(input.runSetup === false ? { teardownCwds: [] } : {}), + teardownCwds: [], paseoHome: input.paseoHome, worktreesBaseRoot: input.worktreesRoot, }, diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index fb23daa6f0..34d1483023 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -411,7 +411,7 @@ test("create_agent_request rejects legacy git options before creating a worktree }, initialPrompt: "Say done.", }), - ).rejects.toThrow("worktree cannot be combined with git options"); + ).rejects.toThrow("createWorkspace"); await expectActiveAgentListEmpty(); await expectWorktreeListEmpty(repoDir); @@ -447,72 +447,6 @@ function storedAgentIds(): string[] { .map((entry) => path.basename(entry, ".json")); } -test.each(["git", "worktreeName"] as const)( - "legacy %s worktree creation resolves final workspace visibility without intermediate records", - async (placement) => { - const cwd = createGitRepo(); - const config = { ...getFullAccessConfig("codex"), cwd }; - const backgroundCaller = await ctx.client.createAgent({ config, background: true }); - const visibleCaller = await ctx.client.createAgent({ config }); - const cases = [ - { name: "review", background: true, expected: true }, - { name: "review-child", callerAgentId: backgroundCaller.id, expected: true }, - { - name: "visible-child", - callerAgentId: backgroundCaller.id, - background: false, - expected: false, - }, - { name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true }, - { name: "human-default", expected: false }, - ]; - const orderedCases = - placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases; - for (const entry of orderedCases) { - const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); - const child = await ctx.client.createAgent({ - config, - ...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}), - ...(entry.background !== undefined ? { background: entry.background } : {}), - ...(placement === "git" - ? { - git: { - createWorktree: true, - createNewBranch: true, - newBranchName: entry.name, - baseBranch: "main", - }, - } - : { worktreeName: entry.name }), - }); - const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); - const created = inclusive.entries.filter( - (workspace) => !before.entries.some((existing) => existing.id === workspace.id), - ); - expect(created).toHaveLength(1); - expect(created[0]).toMatchObject({ - id: child.workspaceId, - workspaceDirectory: child.cwd, - background: entry.expected, - workspaceKind: "worktree", - }); - expect(child.cwd).not.toBe(cwd); - expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId); - const defaultAgents = await ctx.client.fetchAgents(); - const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } }); - expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe( - !entry.expected, - ); - expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true); - const defaultWorkspaces = await ctx.client.fetchWorkspaces(); - expect( - defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId), - ).toBe(!entry.expected); - } - }, - 30_000, -); - test("background workspaces keep durable agents addressable through restart and archive", async () => { const cwd = createGitRepo(); const config = { ...getFullAccessConfig("codex"), cwd }; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e114d8b26e..5b85d12a2b 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -4694,7 +4694,6 @@ describe("session paseo worktree creation handling", () => { await asSessionInternals(session).createPaseoWorktree({ cwd: "/tmp/repo", worktreeSlug: "new-worktree", - runSetup: false, }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 87949594aa..f2e5e28bf2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -29,7 +29,6 @@ import { type SessionInboundMessage, type SessionOutboundMessage, type ScriptStatusUpdateMessage, - type GitSetupOptions, type StartWorkspaceScriptRequest, type WorkspaceScriptListRequest, type WorkspaceScriptStartRequest, @@ -122,10 +121,6 @@ import { updateAgentCommand, } from "./agent/lifecycle-command.js"; import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "./agent/timeline-append.js"; import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js"; import { parsePluginClientId } from "./plugins/plugin-session-identity.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; @@ -249,10 +244,7 @@ import { } from "./paseo-worktree-service.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { - buildAgentSessionConfig as buildWorktreeAgentSessionConfig, - normalizeAgentCreationGitOptions, createPaseoWorktreeWorkflow as createWorktreeWorkflow, - type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, @@ -4293,17 +4285,19 @@ export class Session { } } - private validatePublicAgentWorkspaceIntent( - request: CreateAgentRequestMessage, - createsWorktree: boolean, - ): void { + private validatePublicAgentWorkspaceIntent(request: CreateAgentRequestMessage): void { + if (request.git !== undefined || request.worktreeName !== undefined) { + throw new Error( + "Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) to create a workspace with an agent, or create the workspace first and pass workspaceId.", + ); + } if (request.internal) throw new Error( "Public internal agents were removed; create a background workspace instead.", ); if ( request.background !== undefined && - (request.workspaceId || (request.callerAgentId && !createsWorktree)) + (request.workspaceId || (request.callerAgentId && !request.worktree)) ) { throw new Error( "background is workspace creation intent; configure the selected workspace at creation.", @@ -4322,7 +4316,6 @@ export class Session { initialPrompt, clientMessageId, outputSchema, - git, worktree, autoArchive, images, @@ -4340,14 +4333,11 @@ export class Session { let createdAgentId: string | null = null; try { const requestedCwd = resolve(config.cwd); - const needsRequestedDirectory = - Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId); + const needsRequestedDirectory = Boolean(worktree) || (!msg.workspaceId && !msg.callerAgentId); if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) { throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`); } - // Resolve all supported worktree input forms before provisioning workspace ownership. - const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree); - this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true); + this.validatePublicAgentWorkspaceIntent(msg); const trimmedPrompt = initialPrompt?.trim(); const { provisionalTitle } = resolveCreateAgentTitles({ configTitle: config.title, @@ -4361,9 +4351,8 @@ export class Session { const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext); const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ cwd: config.cwd, - target: gitIntent, + target: worktree, firstAgentContext, - setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(), background: msg.background, callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId), }); @@ -4396,7 +4385,6 @@ export class Session { agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, - worktreeName, initialPrompt, source: msg.callerAgentId ? { kind: "agent-message", agentId: msg.callerAgentId } @@ -4405,19 +4393,10 @@ export class Session { outputSchema, images, attachments, - git, labels: resolvedIntent.intent.labels, env, provisionalTitle, firstAgentContext, - buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) => - createdWorktree - ? { - sessionConfig, - setupContinuation: createdWorktree.setupContinuation, - createdWorkspaceId: createdWorktree.workspace.workspaceId, - } - : this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), }, ); createdAgentId = snapshot.id; @@ -4887,58 +4866,6 @@ export class Session { } } - private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput { - return { - 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, - }; - } - - private async buildAgentSessionConfig( - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, - ): Promise<{ - sessionConfig: AgentSessionConfig; - setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; - createdWorkspaceId?: string; - }> { - return buildWorktreeAgentSessionConfig( - { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - sessionLogger: this.sessionLogger, - workspaceGitService: this.workspaceGitService, - createPaseoWorktree: (input, serviceOptions) => - this.createPaseoWorktreeWorkflow(input, { - ...serviceOptions, - setupContinuation: this.agentWorktreeSetupContinuation(), - }), - checkoutExistingBranch: (cwd, branch) => - this.gitMutation.checkoutExistingBranch(cwd, branch), - createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params), - }, - config, - gitOptions, - legacyWorktreeName, - firstAgentContext, - ); - } - private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { const resolvedRoot = resolve(rootPath); const resolvedCandidate = resolve(candidatePath); @@ -7400,7 +7327,6 @@ export class Session { input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ): Promise { return createWorktreeWorkflow( diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index fafcbc182a..89bc5a43e3 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5704,7 +5704,7 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () = cwd: repoDir, worktreeSlug: "keep", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -5952,7 +5952,7 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t baseBranch: "main", branchName: "worktree-kind-archive", }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index bc65b01d87..aaa79e3da1 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1746,7 +1746,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge paseoHome, worktreeSlug: "restored-pr", source: { kind: "branch-off", baseBranch: baseRef, branchName: "restored-pr" }, - runSetup: false, }); writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); git(created.worktreePath, "add", "."); @@ -1757,7 +1756,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge paseoHome, worktreeSlug: "restored-pr", source: { kind: "restore", branchName: created.branchName, baseRef }, - runSetup: false, }); const requests: Array[0]> = []; const service: ForgeService = { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 964f305eb8..f738a0b0d6 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -153,7 +153,7 @@ describe("workspace recovery", () => { cwd: repoDir, worktreeSlug: "mixed-project", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -209,7 +209,7 @@ describe("workspace recovery", () => { cwd: repoDir, worktreeSlug: "without-subproject", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -303,7 +303,6 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { paseoHome, worktreeSlug: "base-recovery", source: { kind: "branch-off", branchName: "feature", baseBranch: "main" }, - runSetup: false, }); writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "."], { cwd: created.worktreePath }); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 8b0e9a853a..1a715dd349 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -184,7 +184,7 @@ export function createWorkspaceRecoveryService(deps: { cwd: sourceRepoRoot, worktreeSlug: basename(previousWorktreePath), source, - runSetup: false, + paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, }); diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 22c8661544..a7b36f521d 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -550,7 +550,7 @@ describe("wire compatibility", () => { refName: undefined, action: undefined, githubPrNumber: undefined, - runSetup: false, + paseoHome: "/tmp/paseo-home", }); }); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 87cb758ac5..df229f9221 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -329,7 +329,7 @@ test("archiving an unregistered worktree in a bare repo clears the main checkout cwd: mainCheckout, source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, worktreeSlug: "cached-bare-worktree", - runSetup: false, + paseoHome: ctx.daemon.paseoHome, }); expect(await activeWorkspaceIds()).toEqual(new Set()); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index d1cfb8ada7..a1a901c4d6 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -110,7 +110,7 @@ async function createPaseoOwnedWorktree( baseBranch: "main", branchName: worktreeSlug, }, - runSetup: false, + paseoHome, }); } diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 80208fe63d..fc9efe1684 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -604,12 +604,18 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac ).toContain("No local merge target is recorded"); } +function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string { + return detail.commands.map((command) => command.log).join("\n"); +} + // Setup output is opt-in even for multiple sockets sharing one logical client. test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: true }); const { repoDir, tempRoot } = createGitRepoWithBranch(); const clients: SocketDaemonClient[] = []; const sockets = new Map(); + const setupWireTypes = new Set(); + const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = []; const observations: Array<{ release(): Promise }> = []; const workspaces: Array<{ id: string; workspaceDirectory?: string }> = []; const mcp = new Client({ name: "setup-test", version: "1.0.0" }); @@ -653,6 +659,24 @@ const timer = setInterval(() => { reconnect: { baseDelayMs: 10, maxDelayMs: 10 }, webSocketFactory: (url) => { const socket = new WebSocket(url); + socket.on("message", (data, isBinary) => { + if (isBinary) return; + const frame = JSON.parse(data.toString()); + const message = frame.message ?? frame; + if (message.type === "workspace_setup_progress") { + setupWireTypes.add(message.type); + setupWireDetails.push(message.payload.detail); + } + if ( + message.type === "workspace_setup_status_response" || + message.type === "workspace.setup.subscribe.response" + ) { + if (message.payload.snapshot) { + setupWireTypes.add(message.type); + setupWireDetails.push(message.payload.snapshot.detail); + } + } + }); sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]); return socket as unknown as WebSocketLike; }, @@ -683,7 +707,10 @@ const timer = setInterval(() => { }) .toMatchObject({ status: "running", - detail: { log: expect.stringContaining("setup started") }, + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })], + }, }); }; for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) { @@ -712,7 +739,10 @@ const timer = setInterval(() => { const initial = await details.ready; expect(initial.snapshot).toMatchObject({ status: "running", - detail: { log: expect.stringContaining("setup started") }, + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })], + }, }); await expect .poll( @@ -745,7 +775,7 @@ const timer = setInterval(() => { viewerMessages.filter( (message) => message.type === "workspace_setup_progress" && - message.payload.detail.log.includes("wave-0:"), + setupCommandOutput(message.payload.detail).includes("wave-0:"), ).length, ) .toBeGreaterThan(0); @@ -776,10 +806,12 @@ const timer = setInterval(() => { .filter( (message) => message.type === "workspace_setup_progress" && - message.payload.detail.log.includes("wave-0:"), + setupCommandOutput(message.payload.detail).includes("wave-0:"), ) .map((message) => - message.type === "workspace_setup_progress" ? message.payload.detail.log : "", + message.type === "workspace_setup_progress" + ? setupCommandOutput(message.payload.detail) + : "", ); expect(waveLogs.length).toBe(new Set(waveLogs).size); const restoredSnapshots: unknown[] = []; @@ -792,7 +824,12 @@ const timer = setInterval(() => { await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2); expect(details.subscriptionId).not.toBe(beforeReconnect); expect(restoredSnapshots[1]).toMatchObject({ - snapshot: { detail: { log: expect.stringContaining("wave-0:") } }, + snapshot: { + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("wave-0:") })], + }, + }, }); const subscriptionId = details.subscriptionId; await details.release(); @@ -804,7 +841,8 @@ const timer = setInterval(() => { // Wait until the daemon has consumed the output before checking wire silence. await expect .poll( - async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log, + async () => + (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.commands[0]?.log, { timeout: 30000, }, @@ -815,8 +853,22 @@ const timer = setInterval(() => { ); const resumed = viewer.observeWorkspaceSetup(workspace.id); observations.push(resumed); - expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:"); + expect((await resumed.ready).snapshot?.detail.commands[0]?.log).toContain("wave-1:"); expect(resumed.subscriptionId).not.toBe(subscriptionId); + expect(setupWireTypes).toEqual( + new Set([ + "workspace_setup_progress", + "workspace_setup_status_response", + "workspace.setup.subscribe.response", + ]), + ); + expect(setupWireDetails.length).toBeGreaterThan(0); + expect(setupWireDetails.every((detail) => detail.log === "")).toBe(true); + expect( + setupWireDetails.some((detail) => + detail.commands.some((command) => command.log.includes("wave-1:")), + ), + ).toBe(true); writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail"); await expect .poll( @@ -849,3 +901,30 @@ const timer = setInterval(() => { await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); + +test("agent creation rejects legacy worktree intent without provisioning or executing setup", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await expect( + client.createAgent({ + provider: "codex", + cwd: repoDir, + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "legacy-setup", + worktreeSlug: "legacy-setup", + }, + }), + ).rejects.toThrow("createWorkspace"); + expect((await client.fetchWorkspaces()).entries).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index 41de520d09..36ee6af24f 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -13,31 +13,13 @@ import { } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js"; +import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { ScriptRouteStore } from "./script-proxy.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { isPlatform } from "../test-utils/platform.js"; -import { - createWorktree as createWorktreePrimitive, - type WorktreeConfig, -} from "../utils/worktree.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; -interface CreateAgentWorktreeTestOptions { - cwd: string; - branchName: string; - baseBranch: string; - worktreeSlug: string; - paseoHome?: string; -} - -interface CreateAgentWorktreeTestResult { - worktree: WorktreeConfig; - shouldBootstrap: boolean; -} - async function cleanupTerminalManager(terminalManager: TerminalManager): Promise { const terminalsByCwd = await Promise.all( terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)), @@ -54,28 +36,10 @@ function killTerminal(terminalManager: TerminalManager, terminal: TerminalSessio }); } -async function createBootstrapWorktreeForTest( - options: CreateAgentWorktreeTestOptions, -): Promise { - const worktree = await createWorktreePrimitive({ - cwd: options.cwd, - worktreeSlug: options.worktreeSlug, - source: { - kind: "branch-off", - baseBranch: options.baseBranch, - branchName: options.branchName, - }, - runSetup: false, - paseoHome: options.paseoHome, - }); - return { worktree, shouldBootstrap: true }; -} - describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { - describe("runAsyncWorktreeBootstrap", () => { + describe("workspace scripts", () => { let tempDir: string; let repoDir: string; - let paseoHome: string; let realTerminalManagers: TerminalManager[]; async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { @@ -108,7 +72,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { realTerminalManagers = []; tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); repoDir = join(tempDir, "repo"); - paseoHome = join(tempDir, "paseo-home"); mkdirSync(repoDir, { recursive: true }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); @@ -129,290 +92,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { await Promise.all(realTerminalManagers.map(cleanupTerminalManager)); rmSync(tempDir, { recursive: true, force: true }); }); - it("streams running setup updates live and persists only a final setup timeline row", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-streaming-setup", - baseBranch: "main", - worktreeSlug: "feature-streaming-setup", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - const live: AgentTimelineItem[] = []; - - await runAsyncWorktreeBootstrap({ - agentId: "agent-test", - workspaceId: "ws-agent-test", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async (item: AgentTimelineItem) => { - live.push(item); - return true; - }, - }); - - const liveSetupItems = live.filter( - (item) => - item.type === "tool_call" && - item.name === "paseo_worktree_setup" && - item.status === "running", - ); - expect(liveSetupItems.length).toBeGreaterThan(0); - - const persistedSetupItems = persisted.filter( - (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItems).toHaveLength(1); - expect(persistedSetupItems[0]?.type).toBe("tool_call"); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(persistedSetupItems[0].status).toBe("completed"); - expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); - - if (persistedSetupItems[0].detail.type === "worktree_setup") { - expect(persistedSetupItems[0].detail.log).toContain( - '==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2', - ); - expect(persistedSetupItems[0].detail.log).toContain("line-one"); - expect(persistedSetupItems[0].detail.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.log).toContain( - '==> [2/2] Running: echo "line-three"', - ); - expect(persistedSetupItems[0].detail.log).toContain("line-three"); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); - - expect(persistedSetupItems[0].detail.commands).toHaveLength(2); - expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ - index: 1, - command: 'echo "line-one"; echo "line-two" 1>&2', - log: expect.stringContaining("line-one"), - status: "completed", - exitCode: 0, - }); - expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ - index: 2, - command: 'echo "line-three"', - log: "line-three\n", - status: "completed", - exitCode: 0, - }); - expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe( - true, - ); - expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe( - true, - ); - } - } - - const liveCallIds = new Set( - liveSetupItems - .filter( - (item): item is Extract => - item.type === "tool_call", - ) - .map((item) => item.callId), - ); - expect(liveCallIds.size).toBe(1); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); - } - }); - - it("keeps only the final carriage-return-updated content in command logs", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [ - `node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`, - ], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync( - "git", - ["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"], - { - cwd: repoDir, - stdio: "pipe", - }, - ); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-carriage-return", - baseBranch: "main", - worktreeSlug: "feature-carriage-return", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-carriage-return", - workspaceId: "ws-carriage-return", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const persistedSetupItem = persisted.find( - (item): item is Extract => - item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); - if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { - throw new Error("Expected worktree_setup tool detail"); - } - - expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n"); - expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n"); - }); - - it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], - terminals: [ - { - name: "Port Terminal", - command: "true", - }, - ], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync( - "git", - ["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"], - { - cwd: repoDir, - stdio: "pipe", - }, - ); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-shared-runtime-port", - baseBranch: "main", - worktreeSlug: "feature-shared-runtime-port", - paseoHome, - }); - - const registeredEnvs: Array<{ cwd: string; env: Record }> = []; - const createTerminalEnvs: Record[] = []; - const createTerminalWorkspaceIds: Array = []; - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-shared-runtime-port", - workspaceId: "ws-shared-runtime-port", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: { - async getTerminals() { - return []; - }, - async createTerminal(options) { - createTerminalEnvs.push(options.env ?? {}); - createTerminalWorkspaceIds.push(options.workspaceId); - return { - id: "term-1", - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit: () => () => {}, - onCommandFinished: () => () => {}, - onTitleChange: () => () => {}, - getSize: () => ({ rows: 1, cols: 1 }), - getTitle: () => undefined, - getExitInfo: () => null, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), - kill: () => {}, - killAndWait: async () => {}, - }; - }, - registerCwdEnv(options) { - registeredEnvs.push({ cwd: options.cwd, env: options.env }); - }, - getTerminal() { - return undefined; - }, - killTerminal() {}, - async killTerminalAndWait() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt"); - await waitForPathExists(setupPortPath); - - const setupPort = readFileSync(setupPortPath, "utf8").trim(); - expect(setupPort.length).toBeGreaterThan(0); - expect(registeredEnvs).toHaveLength(1); - expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath); - expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); - expect(createTerminalEnvs.length).toBeGreaterThan(0); - expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); - expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]); - - const terminalToolCall = persisted.find( - (item): item is Extract => - item.type === "tool_call" && - item.name === "paseo_worktree_terminals" && - item.status === "completed", - ); - expect(terminalToolCall?.status).toBe("completed"); - }); it("injects real peer service env into terminal-backed services", async () => { writeFileSync( diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 5e5ac1762e..96da6e648b 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -3,9 +3,11 @@ import { execFileSync } from "child_process"; import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; - -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js"; +import { + buildWorktreeSetupDetail, + runWorktreeAutoTerminals, + spawnWorkspaceScript, +} from "./worktree-bootstrap.js"; import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptRouteStore } from "./script-proxy.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; @@ -13,6 +15,7 @@ import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js import { createWorktree as createWorktreePrimitive, type WorktreeConfig, + resolveWorktreeRuntimeEnv, } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; @@ -57,13 +60,58 @@ async function createBootstrapWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: false, + paseoHome: options.paseoHome, }); return { worktree, shouldBootstrap: true }; } -describe("runAsyncWorktreeBootstrap", () => { +describe("workspace setup detail", () => { + it("serializes output only in command logs while retaining the legacy string field", () => { + const detail = buildWorktreeSetupDetail({ + worktree: { worktreePath: "/workspace", branchName: "feature" }, + results: [ + { + command: "echo ready", + cwd: "/workspace", + stdout: "ready\n", + stderr: "", + exitCode: 0, + durationMs: 10, + }, + ], + }); + const serialized = JSON.stringify(detail); + expect(JSON.parse(serialized)).toMatchObject({ + log: "", + commands: [{ log: "ready\n", status: "completed", exitCode: 0 }], + }); + expect(serialized.split("ready\\n")).toHaveLength(2); + }); + it("retains bounded per-command output and truncation without an aggregate copy", () => { + const detail = buildWorktreeSetupDetail({ + worktree: { worktreePath: "/workspace", branchName: "feature" }, + results: [ + { + command: "large-output", + cwd: "/workspace", + stdout: "prefix-" + "x".repeat(100000) + "-suffix", + stderr: "", + exitCode: 0, + durationMs: 10, + }, + ], + }); + expect(detail.log).toBe(""); + expect(detail.truncated).toBe(true); + expect(detail.commands[0]?.log).toContain("prefix-"); + expect(detail.commands[0]?.log).toContain("-suffix"); + expect(detail.commands[0]?.log).toContain("......"); + expect(Buffer.byteLength(detail.commands[0]!.log)).toBeLessThanOrEqual(64 * 1024); + }); +}); + +describe("runWorktreeAutoTerminals", () => { let tempDir: string; let repoDir: string; let paseoHome: string; @@ -92,116 +140,6 @@ describe("runAsyncWorktreeBootstrap", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("does not fail setup when live timeline emission throws", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "ok"'], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-live-failure", - baseBranch: "main", - worktreeSlug: "feature-live-failure", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await expect( - runAsyncWorktreeBootstrap({ - agentId: "agent-live-failure", - workspaceId: "ws-live-failure", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => { - throw new Error("live emit failed"); - }, - }), - ).resolves.toBeUndefined(); - - const persistedSetupItems = persisted.filter( - (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItems).toHaveLength(1); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(persistedSetupItems[0].status).toBe("completed"); - } - }); - - it("truncates each command output to 64kb in the middle", async () => { - const largeOutputCommand = - "node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\""; - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [largeOutputCommand], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-large-output", - baseBranch: "main", - worktreeSlug: "feature-large-output", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-large-output", - workspaceId: "ws-large-output", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const persistedSetupItem = persisted.find( - (item): item is Extract => - item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItem).toBeDefined(); - expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); - if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { - throw new Error("Expected worktree_setup tool detail"); - } - - expect(persistedSetupItem.detail.truncated).toBe(true); - expect(persistedSetupItem.detail.log).toContain("prefix-"); - expect(persistedSetupItem.detail.log).toContain("-suffix"); - expect(persistedSetupItem.detail.log).toContain("......"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain( - "......", - ); - }); - it("waits for terminal output before sending bootstrap commands", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -238,11 +176,11 @@ describe("runAsyncWorktreeBootstrap", () => { let sendAt = 0; let outputListener: ((chunk: { data: string }) => void) | null = null; - await runAsyncWorktreeBootstrap({ - agentId: "agent-terminal-readiness", + await runWorktreeAutoTerminals({ workspaceId: "ws-terminal-readiness", worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, + workspaceCwd: worktreeBootstrap.worktree.worktreePath, + runtimeEnv: await resolveWorktreeRuntimeEnv(worktreeBootstrap.worktree), terminalManager: { async getTerminals() { return []; @@ -296,8 +234,6 @@ describe("runAsyncWorktreeBootstrap", () => { return () => {}; }, }, - appendTimelineItem: async () => true, - emitLiveTimelineItem: async () => true, }); expect(readyAt).toBeGreaterThan(0); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 4bd49b1c2d..6e138e8ab4 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,4 +1,3 @@ -import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; @@ -9,9 +8,7 @@ import { paseoConfigParseError, processCarriageReturns, readPaseoConfig, - resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, - WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, @@ -19,7 +16,7 @@ import { import type { ServiceProxySubsystem } from "./service-proxy.js"; import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; +import type { ToolCallDetail } from "./agent/agent-sdk-types.js"; import { assertNoServiceEnvNameCollisions, buildWorkspaceServiceEnv, @@ -32,28 +29,6 @@ import { } from "./workspace-service-port-registry.js"; import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; -export interface WorktreeBootstrapTerminalResult { - name: string | null; - command: string; - status: "started" | "failed"; - terminalId: string | null; - error: string | null; -} - -export interface RunAsyncWorktreeBootstrapOptions { - agentId: string; - // Workspace the bootstrapped terminals belong to. Stamping it lets - // workspaceId-scoped archive tear these terminals down. - workspaceId: string; - worktree: WorktreeConfig; - workspaceCwd?: string; - shouldBootstrap?: boolean; - terminalManager: TerminalManager | null; - appendTimelineItem: (item: AgentTimelineItem) => Promise; - emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; - logger?: Logger; -} - const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; @@ -170,10 +145,6 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula }; } -function formatDurationMs(durationMs: number): string { - return `${(durationMs / 1000).toFixed(2)}s`; -} - function commandStatusFromResult( result: WorktreeSetupCommandResult, ): "running" | "completed" | "failed" { @@ -183,46 +154,6 @@ function commandStatusFromResult( return result.exitCode === 0 ? "completed" : "failed"; } -function buildWorktreeSetupLog(input: { - results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; -}): { log: string; truncated: boolean } { - const { results, outputAccumulatorsByIndex } = input; - if (results.length === 0) { - return { - log: "", - truncated: false, - }; - } - - const lines: string[] = []; - let anyTruncated = false; - const total = results.length; - for (const [index, result] of results.entries()) { - lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); - const output = buildWorktreeSetupCommandLog({ - index: index + 1, - result, - outputAccumulatorsByIndex, - }); - if (output.log.length > 0) { - lines.push(output.log.replace(/\n$/, "")); - } - if (output.truncated) { - anyTruncated = true; - } - if (result.exitCode !== null) { - lines.push( - `<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`, - ); - } - } - return { - log: lines.join("\n"), - truncated: anyTruncated, - }; -} - function buildWorktreeSetupCommandLog(input: { index: number; result: WorktreeSetupCommandResult; @@ -325,124 +256,15 @@ export function buildWorktreeSetupDetail(input: { ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), }; }); - const renderedLog = buildWorktreeSetupLog({ - results: input.results, - outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, - }); - return { type: "worktree_setup", worktreePath: input.worktree.worktreePath, branchName: input.worktree.branchName, - log: renderedLog.log, + // COMPAT(workspaceSetupCombinedLog): added in v0.12.0, remove after 2027-04-20 once legacy wire schemas no longer require log. + // Setup clients render commands[].log; retain the required wire field without duplicating output. + log: "", commands, - ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}), - }; -} - -function buildSetupTimelineItem(input: { - callId: string; - status: "running" | "completed" | "failed"; - worktree: WorktreeConfig; - results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; - errorMessage: string | null; -}): AgentTimelineItem { - const detail = buildWorktreeSetupDetail({ - worktree: input.worktree, - results: input.results, - outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, - }); - - if (input.status === "running") { - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "running", - detail, - error: null, - }; - } - - if (input.status === "completed") { - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "completed", - detail, - error: null, - }; - } - - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "failed", - detail, - error: { message: input.errorMessage ?? "Worktree setup failed" }, - }; -} - -function buildTerminalTimelineItem(input: { - callId: string; - status: "running" | "completed" | "failed"; - worktree: WorktreeConfig; - results: WorktreeBootstrapTerminalResult[]; - errorMessage: string | null; -}): AgentTimelineItem { - const detailInput = { - worktreePath: input.worktree.worktreePath, - branchName: input.worktree.branchName, - }; - const detailOutput = { - worktreePath: input.worktree.worktreePath, - terminals: input.results, - }; - - if (input.status === "running") { - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "running", - detail: { - type: "unknown", - input: detailInput, - output: null, - }, - error: null, - }; - } - - if (input.status === "completed") { - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "completed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, - error: null, - }; - } - - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "failed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, - error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" }, + ...(anyCommandTruncated ? { truncated: true } : {}), }; } @@ -502,93 +324,6 @@ function terminalHasOutput(state: ReturnType): bool return false; } -async function runWorktreeTerminalBootstrap( - options: RunAsyncWorktreeBootstrapOptions, - runtimeEnv: WorktreeRuntimeEnv, -): Promise { - const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; - const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd); - if (terminalSpecs.length === 0) { - return; - } - - const callId = uuidv4(); - const started = await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "running", - worktree: options.worktree, - results: [], - errorMessage: null, - }), - ); - if (!started) { - return; - } - - if (!options.terminalManager) { - await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "failed", - worktree: options.worktree, - results: [], - errorMessage: "Terminal manager not available", - }), - ); - return; - } - - const terminalManager = options.terminalManager; - const results = await Promise.all( - terminalSpecs.map(async (spec): Promise => { - try { - const terminal = await terminalManager.createTerminal({ - cwd: workspaceCwd, - name: spec.name, - env: runtimeEnv, - workspaceId: options.workspaceId, - }); - await waitForTerminalBootstrapReadiness(terminal); - terminal.send({ - type: "input", - data: `${spec.command}\r`, - }); - return { - name: terminal.name ?? spec.name ?? null, - command: spec.command, - status: "started", - terminalId: terminal.id, - error: null, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - options.logger?.warn( - { agentId: options.agentId, command: spec.command, err: error }, - "Failed to bootstrap worktree terminal", - ); - return { - name: spec.name ?? null, - command: spec.command, - status: "failed", - terminalId: null, - error: message, - }; - } - }), - ); - - await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "completed", - worktree: options.worktree, - results, - errorMessage: null, - }), - ); -} - export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; @@ -597,117 +332,27 @@ export async function runWorktreeAutoTerminals(options: { terminalManager: TerminalManager | null; logger?: Logger; }): Promise { - await runWorktreeTerminalBootstrap( - { - agentId: options.workspaceId, - workspaceId: options.workspaceId, - worktree: options.worktree, - workspaceCwd: options.workspaceCwd, - terminalManager: options.terminalManager, - appendTimelineItem: async () => true, - logger: options.logger, - }, - options.runtimeEnv, - ); -} - -export async function runAsyncWorktreeBootstrap( - options: RunAsyncWorktreeBootstrapOptions, -): Promise { - if (options.shouldBootstrap === false) { - return; - } - - const setupCallId = uuidv4(); - let setupResults: WorktreeSetupCommandResult[] = []; - let runtimeEnv: WorktreeRuntimeEnv | null = null; - const emitLiveTimelineItem = options.emitLiveTimelineItem; - const progressAccumulator = createWorktreeSetupProgressAccumulator(); - const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; - let liveEmitQueue = Promise.resolve(); - - const queueLiveRunningEmit = () => { - if (!emitLiveTimelineItem) { - return; - } - const runningResults = getWorktreeSetupProgressResults(progressAccumulator); - liveEmitQueue = liveEmitQueue.then(async () => { + if (!options.terminalManager) return; + const terminalManager = options.terminalManager; + await Promise.all( + getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => { try { - await emitLiveTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "running", - worktree: options.worktree, - results: runningResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: null, - }), - ); + const terminal = await terminalManager.createTerminal({ + cwd: options.workspaceCwd, + name: spec.name, + env: options.runtimeEnv, + workspaceId: options.workspaceId, + }); + await waitForTerminalBootstrapReadiness(terminal); + terminal.send({ type: "input", data: `${spec.command}\r` }); } catch (error) { options.logger?.warn( - { err: error, agentId: options.agentId }, - "Failed to emit live worktree setup timeline update", + { workspaceId: options.workspaceId, command: spec.command, err: error }, + "Failed to bootstrap worktree terminal", ); } - return; - }); - }; - - try { - runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktree.worktreePath, - branchName: options.worktree.branchName, - }); - options.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); - - setupResults = await runWorktreeSetupCommands({ - worktreePath: workspaceCwd, - branchName: options.worktree.branchName, - cleanupOnFailure: false, - runtimeEnv, - onEvent: (event) => { - applyWorktreeSetupProgressEvent(progressAccumulator, event); - queueLiveRunningEmit(); - }, - }); - await liveEmitQueue; - - const completed = await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "completed", - worktree: options.worktree, - results: setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: null, - }), - ); - if (!completed) { - return; - } - } catch (error) { - if (error instanceof WorktreeSetupError) { - setupResults = error.results; - } - await liveEmitQueue; - const message = error instanceof Error ? error.message : String(error); - await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "failed", - worktree: options.worktree, - results: setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: message, - }), - ); - return; - } - - await runWorktreeTerminalBootstrap(options, runtimeEnv); + }), + ); } // --------------------------------------------------------------------------- diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index d5f85ae364..9d02d0a2ec 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -405,7 +405,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "legacy-rpc", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -431,7 +430,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "branch-off", refName: "main", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -452,7 +450,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { { cwd: repoDir, paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -475,7 +472,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -502,7 +498,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -520,7 +515,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "mcp-standalone", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -547,7 +541,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "branch-off", refName: "dev", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -576,7 +569,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", refName: "dev", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -604,7 +596,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 123, paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -656,7 +647,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -694,7 +684,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -746,7 +735,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -796,7 +784,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -841,7 +828,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -852,7 +838,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -910,7 +895,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -955,7 +939,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -997,7 +980,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1008,7 +990,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1118,7 +1099,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1129,7 +1109,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1179,7 +1158,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1285,7 +1263,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1383,7 +1360,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1395,7 +1371,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1417,7 +1392,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", refName: "missing-branch", paseoHome, - runSetup: false, }, createCoreDeps(), ), @@ -1433,7 +1407,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "agent-worktree", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -1452,11 +1425,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { const deps = createCoreDeps(); const first = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome }, deps, ); const second = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome }, deps, ); @@ -1475,7 +1448,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }; const first = await createCoreWorktree(input, deps); @@ -1515,7 +1487,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { worktreeSlug: "stubbed-github", githubPrNumber: 123, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index c66e91c5dd..b0310d6e1f 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -28,7 +28,6 @@ export interface CreateWorktreeCoreInput { firstAgentContext?: FirstAgentContext; paseoHome?: string; worktreesRoot?: string; - runSetup?: boolean; } export interface CreateWorktreeCoreDeps { @@ -120,7 +119,6 @@ async function createWorktreeCoreWithPriority( cwd: repoRoot, worktreeSlug: normalizedSlug, source: intent, - runSetup: input.runSetup ?? true, paseoHome: input.paseoHome, worktreesRoot: input.worktreesRoot, }), diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index b146c59f31..d462a2605a 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -15,11 +15,9 @@ import pino, { type Logger } from "pino"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; import { - buildAgentSessionConfig, createPaseoWorktreeWorkflow, handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeListRequest, - resolveGitCreateBaseBranch, runWorktreeSetupInBackground, handleCreatePaseoWorktreeRequest, handleWorkspaceSetupStatusRequest, @@ -57,7 +55,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -76,7 +74,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } @@ -122,7 +120,6 @@ function createWorkflowForRequestTest(options: { onScriptsChanged: null, }, input, - { setupContinuation: { kind: "workspace" } }, ).then((result) => { options.onSetupStarted?.({ requestCwd: input.cwd, @@ -428,30 +425,6 @@ describe("handlePaseoWorktreeListRequest", () => { }); }); -describe("resolveGitCreateBaseBranch", () => { - test("resolves the default branch through the workspace git service", async () => { - const { tempDir, repoDir } = createGitRepo(); - const cwd = path.join(repoDir, "packages", "app"); - const workspaceGitService = { - resolveDefaultBranch: vi.fn().mockResolvedValue("main"), - getSnapshot: vi.fn(async () => { - throw new Error("getSnapshot should not be used for default-branch resolution"); - }), - }; - - try { - await expect( - resolveGitCreateBaseBranch(cwd, workspaceGitService as unknown as WorkspaceGitService), - ).resolves.toBe("main"); - - expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); -}); - describe("create-agent worktree setup boundary", () => { test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => { const { tempDir, repoDir } = createGitRepo(); @@ -469,7 +442,7 @@ describe("create-agent worktree setup boundary", () => { ); try { - const result = await createPaseoWorktreeWorkflow( + await createPaseoWorktreeWorkflow( { paseoHome, createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), @@ -494,19 +467,9 @@ describe("create-agent worktree setup boundary", () => { getDaemonTcpHost: null, onScriptsChanged: null, }, - { cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome }, - { - setupContinuation: { - kind: "agent", - terminalManager: createTerminalManagerStub().manager, - appendTimelineItem: async () => true, - logger: createLogger(), - }, - }, + { cwd: repoDir, worktreeSlug: "blocked-fork", paseoHome }, ); - expect(result.setupContinuation?.kind).toBe("agent"); - result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" }); expect(existsSync(setupMarker)).toBe(false); expect(emitted).toContainEqual( expect.objectContaining({ @@ -518,79 +481,6 @@ describe("create-agent worktree setup boundary", () => { rmSync(tempDir, { recursive: true, force: true }); } }); - - 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 () => {}, - autoNameWorkspaceBranchForFirstAgent: () => {}, - emitWorkspaceUpdateForWorkspaceId: async () => {}, - cacheWorkspaceSetupSnapshot: () => {}, - emit: (message) => workspaceSetupEvents.push(message), - sessionLogger: createLogger(), - terminalManager: null, - serviceProxy: 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 { @@ -698,7 +588,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-subdirectory-setup", - runSetup: false, + paseoHome, }); const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app"); @@ -746,7 +636,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-no-setup", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -842,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "broken-feature", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -916,7 +806,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-running-setup", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -983,7 +873,7 @@ describe("runWorktreeSetupInBackground", () => { const setupOutputMessage = runningMessages.find((message) => message.payload.detail.commands[0]?.log.includes("phase-one"), ); - expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); + expect(setupOutputMessage?.payload.detail.log).toBe(""); expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ index: 1, command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", @@ -1001,7 +891,7 @@ describe("runWorktreeSetupInBackground", () => { branchName: "feature-running-setup", }, }); - expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two"); + expect(progressMessages.at(-1)?.payload.detail.log).toBe(""); expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ index: 1, command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", @@ -1037,7 +927,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "reused-worktree", - runSetup: false, + paseoHome, }); @@ -1125,7 +1015,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-service-failure", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -1208,7 +1098,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-socket-mode", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -1508,221 +1398,6 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(readme).toContain("review branch"); }); - test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => { - const { tempDir, repoDir } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const events: string[] = []; - - const result = await buildAgentSessionConfig( - { - paseoHome: path.join(tempDir, ".paseo"), - sessionLogger: createLogger(), - workspaceGitService: { - resolveRepoRoot: vi.fn(async () => repoDir), - resolveDefaultBranch: vi.fn(async () => "main"), - } as unknown as WorkspaceGitService, - createPaseoWorktree: createPaseoWorktreeForTest({ - paseoHome: path.join(tempDir, ".paseo"), - events, - }), - checkoutExistingBranch: async () => { - throw new Error("should not checkout existing branch"); - }, - createBranchFromBase: async () => { - throw new Error("should not create a new branch from base"); - }, - }, - { - provider: "codex", - cwd: repoDir, - }, - { - createWorktree: true, - worktreeSlug: "agent-review-pr-123", - action: "checkout", - githubPrNumber: 123, - refName: "feature/review-pr", - }, - ); - - expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); - expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); - - const branch = execFileSync("git", ["branch", "--show-current"], { - cwd: result.sessionConfig.cwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branch).toBe("feature/review-pr"); - }); - - test("buildAgentSessionConfig uses the normalized new branch name as the worktree slug fallback", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const paseoHome = path.join(tempDir, ".paseo"); - - const result = await buildAgentSessionConfig( - { - paseoHome, - sessionLogger: createLogger(), - workspaceGitService: { - resolveRepoRoot: vi.fn(async () => repoDir), - resolveDefaultBranch: vi.fn(async () => "main"), - } as unknown as WorkspaceGitService, - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), - 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: repoDir, - }, - { - createWorktree: true, - createNewBranch: true, - newBranchName: "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", - branchName: "fix-attached-pr-context", - }, - workspace: { - workspaceId: "ws-fix-attached-pr-context", - projectId: "/tmp/repo", - cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app", - 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 firstAgentContext = { - prompt: "Create a worktree name from this prompt", - attachments: [ - { - type: "github_pr" as const, - 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, - firstAgentContext, - ); - - expect(createPaseoWorktree).toHaveBeenCalledWith( - expect.objectContaining({ - firstAgentContext, - }), - expect.anything(), - ); - expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app"); - }); - - test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { - const invalidate = vi.fn(); - const createBranchFromBase = vi.fn(async () => {}); - const checkoutExistingBranch = vi.fn(async () => ({ source: "local" as const })); - const createPaseoWorktree = vi.fn(async () => { - throw new Error("should not create worktree"); - }); - - await buildAgentSessionConfig( - { - sessionLogger: createLogger(), - createPaseoWorktree, - checkoutExistingBranch, - createBranchFromBase, - workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService, - }, - { - provider: "codex", - cwd: "/tmp/repo", - }, - { - createNewBranch: true, - baseBranch: "main", - newBranchName: "feature-x", - }, - ); - - expect(createBranchFromBase).toHaveBeenCalledWith({ - cwd: "/tmp/repo", - baseBranch: "main", - newBranchName: "feature-x", - }); - expect(invalidate).toHaveBeenCalledWith("/tmp/repo"); - - invalidate.mockClear(); - - await buildAgentSessionConfig( - { - sessionLogger: createLogger(), - createPaseoWorktree, - checkoutExistingBranch, - createBranchFromBase, - workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService, - }, - { - provider: "codex", - cwd: "/tmp/repo", - }, - { - baseBranch: "release", - }, - ); - - expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release"); - expect(invalidate).toHaveBeenCalledWith("/tmp/repo"); - }); - test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1734,7 +1409,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { cwd: repoDir, worktreeSlug: "resolver-feature", action: "branch-off", - runSetup: false, + paseoHome, }, { resolveDefaultBranch }, @@ -1996,7 +1671,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-worktree-scope", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; @@ -2072,7 +1747,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-default-scope", - runSetup: false, + paseoHome, }); const workspaceId = "ws-default-scope"; @@ -2146,7 +1821,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-default-scope-sibling", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; @@ -2223,7 +1898,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-delete-flag", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 02b1625228..4ba1010bd3 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -1,12 +1,7 @@ import type { Logger } from "pino"; import { basename } from "node:path"; - -import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import { - type GitSetupOptions, - type CreateAgentWorktreeTarget, type FirstAgentContext, - type ChangeRequestCheckoutSource, type SessionInboundMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, @@ -15,7 +10,6 @@ import { import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { - runAsyncWorktreeBootstrap, runWorktreeAutoTerminals, applyWorktreeSetupProgressEvent, buildWorktreeSetupDetail, @@ -25,14 +19,10 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; -import { expandTilde } from "../utils/path.js"; import { getWorktreeSetupCommands, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, - slugify, - validateBranchSlug, type WorktreeConfig, type WorktreeSetupCommandResult, WorktreeSetupError, @@ -57,49 +47,7 @@ import { const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; -export interface NormalizedGitOptions { - baseBranch?: string; - createNewBranch: boolean; - newBranchName?: string; - createWorktree: boolean; - worktreeSlug?: string; - requestedWorktreeSlug?: string; - refName?: string; - action?: "branch-off" | "checkout"; - checkoutSource?: ChangeRequestCheckoutSource; - githubPrNumber?: number; -} - 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; - worktreesRoot?: string; - sessionLogger: Logger; - workspaceGitService?: WorkspaceGitService; - createPaseoWorktree: ( - input: CreatePaseoWorktreeInput, - options?: { - resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; - }, - ) => Promise; - checkoutExistingBranch: (cwd: string, branch: string) => Promise; - createBranchFromBase: (params: { - cwd: string; - baseBranch: string; - newBranchName: string; - }) => Promise; -} interface CreatePaseoWorktreeInBackgroundDependencies { paseoHome?: string; @@ -133,32 +81,12 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB assertWorkspaceAutomationAllowed?: (workspaceId: string) => 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 CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult; export type CreatePaseoWorktreeWorkflowFn = ( input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ) => Promise; @@ -204,245 +132,12 @@ function normalizeFirstAgentContext( return undefined; } -export async function buildAgentSessionConfig( - dependencies: BuildAgentSessionConfigDependencies, - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, -): Promise<{ - sessionConfig: AgentSessionConfig; - setupContinuation?: AgentWorktreeSetupContinuation; - createdWorkspaceId?: string; -}> { - let cwd = expandTilde(config.cwd); - const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); - let setupContinuation: AgentWorktreeSetupContinuation | undefined; - let createdWorkspaceId: string | undefined; - - if (!normalized) { - return { - sessionConfig: { - ...config, - cwd, - }, - }; - } - - if (normalized.createWorktree) { - dependencies.sessionLogger.info( - { worktreeSlug: normalized.requestedWorktreeSlug }, - "Creating worktree through createWorktreeCore", - ); - - const createdWorktree = await dependencies.createPaseoWorktree( - { - cwd, - worktreeSlug: normalized.worktreeSlug, - refName: normalized.refName, - action: normalized.action, - checkoutSource: normalized.checkoutSource, - githubPrNumber: normalized.githubPrNumber, - firstAgentContext, - runSetup: false, - paseoHome: dependencies.paseoHome, - worktreesRoot: dependencies.worktreesRoot, - }, - { - resolveDefaultBranch: normalized.baseBranch - ? async () => normalized.baseBranch! - : (repoRoot) => - resolveGitCreateBaseBranch( - repoRoot, - dependencies.workspaceGitService, - dependencies.paseoHome, - ), - }, - ); - cwd = createdWorktree.workspace.cwd; - setupContinuation = createdWorktree.setupContinuation; - createdWorkspaceId = createdWorktree.workspace.workspaceId; - } else if (normalized.createNewBranch) { - const baseBranch = - normalized.baseBranch ?? - (await resolveGitCreateBaseBranch( - cwd, - dependencies.workspaceGitService, - dependencies.paseoHome, - )); - await dependencies.createBranchFromBase({ - cwd, - baseBranch, - newBranchName: normalized.newBranchName!, - }); - dependencies.workspaceGitService?.invalidateForge(cwd); - } else if (normalized.baseBranch) { - await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch); - dependencies.workspaceGitService?.invalidateForge(cwd); - } - - return { - sessionConfig: { - ...config, - cwd, - }, - setupContinuation, - createdWorkspaceId, - }; -} - -interface ValidateNormalizedGitOptionsInput { - baseBranch: string | undefined; - createNewBranch: boolean; - normalizedBranchName: string | undefined; - normalizedWorktreeSlug: string | undefined; -} - -function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): void { - if (input.baseBranch) { - assertSafeGitRef(input.baseBranch, "base branch"); - } - - if (input.createNewBranch) { - if (!input.normalizedBranchName) { - throw new Error("New branch name is required"); - } - const validation = validateBranchSlug(input.normalizedBranchName); - if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); - } - } - - if (input.normalizedWorktreeSlug) { - const validation = validateBranchSlug(input.normalizedWorktreeSlug); - if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); - } - } -} - -export function normalizeAgentCreationGitOptions( - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - worktree?: CreateAgentWorktreeTarget, -): NormalizedGitOptions | null { - if (worktree && gitOptions) { - throw new Error("create_agent_request worktree cannot be combined with git options"); - } - if (worktree) { - switch (worktree.mode) { - case "branch-off": - gitOptions = { - createWorktree: true, - createNewBranch: true, - newBranchName: worktree.newBranch, - baseBranch: worktree.base, - action: "branch-off", - refName: worktree.base, - }; - break; - case "checkout-branch": - gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch }; - break; - case "checkout-pr": - gitOptions = { - createWorktree: true, - action: "checkout", - githubPrNumber: worktree.prNumber, - }; - break; - } - } - return normalizeGitOptions(gitOptions, legacyWorktreeName); -} - -function normalizeGitOptions( - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, -): NormalizedGitOptions | null { - const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName - ? { - createWorktree: true, - createNewBranch: true, - newBranchName: legacyWorktreeName, - worktreeSlug: legacyWorktreeName, - } - : undefined; - - const merged = gitOptions ?? fallbackOptions; - if (!merged) { - return null; - } - - const baseBranch = merged.baseBranch?.trim() || undefined; - const createWorktree = Boolean(merged.createWorktree); - const createNewBranch = Boolean(merged.createNewBranch); - const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined; - const requestedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) : undefined; - const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName; - const refName = merged.refName?.trim() || undefined; - const action = merged.action; - // COMPAT(githubPrNumber): legacy GitHub checkout input retained when - // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the - // supported client floor is >= v0.2.0. - const checkoutSource = - merged.checkoutSource ?? - (merged.githubPrNumber - ? ({ kind: "change_request", forge: "github", number: merged.githubPrNumber } as const) - : undefined); - const githubPrNumber = merged.githubPrNumber; - - if ( - !createWorktree && - !createNewBranch && - !baseBranch && - !refName && - !action && - !checkoutSource && - !githubPrNumber - ) { - return null; - } - - validateNormalizedGitOptions({ - baseBranch, - createNewBranch, - normalizedBranchName, - normalizedWorktreeSlug, - }); - - return { - baseBranch, - createNewBranch, - newBranchName: normalizedBranchName, - createWorktree, - worktreeSlug: normalizedWorktreeSlug, - requestedWorktreeSlug, - refName, - action, - checkoutSource, - githubPrNumber, - }; -} - export function assertSafeGitRef(ref: string, label: string): void { if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { throw new Error(`Invalid ${label}: ${ref}`); } } -export async function resolveGitCreateBaseBranch( - cwd: string, - workspaceGitService?: WorkspaceGitService, - _paseoHome?: string, -): Promise { - if (!workspaceGitService) { - throw new Error("WorkspaceGitService is required to resolve the repository root"); - } - - return workspaceGitService.resolveDefaultBranch(cwd); -} - export async function handlePaseoWorktreeListRequest( dependencies: { emit: EmitSessionMessage; @@ -648,13 +343,12 @@ export async function createPaseoWorktreeWorkflow( input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ): Promise { const createdWorktree = await dependencies.createPaseoWorktree( { ...input, - runSetup: false, + paseoHome: input.paseoHome ?? dependencies.paseoHome, worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot, }, @@ -664,7 +358,6 @@ export async function createPaseoWorktreeWorkflow( ); const slug = basename(createdWorktree.worktree.worktreePath); const workspace = createdWorktree.workspace; - const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; try { await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId); @@ -681,15 +374,6 @@ export async function createPaseoWorktreeWorkflow( type: "workspace_setup_progress", payload: { workspaceId: workspace.workspaceId, ...snapshot }, }); - if (setupContinuation.kind === "agent") { - return { - ...createdWorktree, - setupContinuation: { - kind: "agent", - startAfterAgentCreate: () => undefined, - }, - }; - } return createdWorktree; } @@ -706,53 +390,28 @@ export async function createPaseoWorktreeWorkflow( "Failed to warm workspace git data after creating worktree", ); }); - if (setupContinuation.kind === "workspace") { - const runSetup = (signal: AbortSignal) => - runWorktreeSetupInBackground( - dependencies, - { - requestCwd: input.cwd, - repoRoot: createdWorktree.repoRoot, - workspaceId: workspace.workspaceId, - worktree: createdWorktree.worktree, - shouldBootstrap: createdWorktree.created, - slug, - worktreePath: createdWorktree.worktree.worktreePath, - workspaceCwd: workspace.cwd, - }, - signal, - ); - if (dependencies.startWorkspaceSetup) { - dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup); - } else { - void runSetup(new AbortController().signal); - } + const runSetup = (signal: AbortSignal) => + runWorktreeSetupInBackground( + dependencies, + { + requestCwd: input.cwd, + repoRoot: createdWorktree.repoRoot, + workspaceId: workspace.workspaceId, + worktree: createdWorktree.worktree, + shouldBootstrap: createdWorktree.created, + slug, + worktreePath: createdWorktree.worktree.worktreePath, + workspaceCwd: workspace.cwd, + }, + signal, + ); + if (dependencies.startWorkspaceSetup) { + dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup); + } else { + void runSetup(new AbortController().signal); } }, 0); - if (setupContinuation.kind === "agent") { - return { - ...createdWorktree, - setupContinuation: { - kind: "agent", - startAfterAgentCreate: ({ agentId }) => { - void runAsyncWorktreeBootstrap({ - agentId, - workspaceId: workspace.workspaceId, - worktree: createdWorktree.worktree, - workspaceCwd: workspace.cwd, - shouldBootstrap: createdWorktree.created, - terminalManager: setupContinuation.terminalManager, - appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }), - emitLiveTimelineItem: (item) => - setupContinuation.emitLiveTimelineItem({ agentId, item }), - logger: setupContinuation.logger, - }); - }, - }, - }; - } - return createdWorktree; } @@ -911,7 +570,6 @@ export async function runWorktreeSetupInBackground( setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, - cleanupOnFailure: false, repoRootPath: options.repoRoot, runtimeEnv, signal, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 4f76cb1887..0aab346984 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -45,10 +45,7 @@ export interface CreatePaseoWorktreeCommandDependencies< createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow; } -export type CreatePaseoWorktreeCommandInput = Omit< - CreatePaseoWorktreeInput, - "paseoHome" | "runSetup" -> & { +export type CreatePaseoWorktreeCommandInput = Omit & { paseoHome?: string; worktreesRoot?: string; }; @@ -75,7 +72,7 @@ export async function createPaseoWorktreeCommand { await runWorktreeSetupCommands({ worktreePath, branchName: "main", - cleanupOnFailure: false, runtimeEnv: { PASEO_SOURCE_CHECKOUT_PATH: worktreePath, PASEO_ROOT_PATH: worktreePath, diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 2b9181bb77..a550995fb3 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -58,7 +58,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; worktreesRoot?: string; } @@ -78,7 +78,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, }); @@ -239,7 +239,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "my-feature", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" }, - runSetup: true, + paseoHome, }); @@ -282,7 +282,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "existing-feature", source: { kind: "checkout-branch", branchName: "feature/existing" }, - runSetup: false, + paseoHome, }); const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); @@ -304,7 +304,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "dev-worktree", source: { kind: "checkout-branch", branchName: "dev" }, - runSetup: true, + paseoHome, }); @@ -346,7 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "stale-default", source: { kind: "checkout-branch", branchName: "feature/stale-default" }, - runSetup: false, + paseoHome, }); const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome }); @@ -365,7 +365,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "feature-x-worktree", source: { kind: "checkout-branch", branchName: "feature-x" }, - runSetup: true, + paseoHome, }); @@ -382,7 +382,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "release-worktree", source: { kind: "checkout-branch", branchName: "release/1.1.15" }, - runSetup: true, + paseoHome, }); @@ -400,7 +400,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "dev-worktree", source: { kind: "checkout-branch", branchName: "main" }, - runSetup: true, + paseoHome, }); @@ -412,7 +412,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { + it("fetches a GitHub PR branch and writes metadata without executing setup", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); @@ -446,12 +446,12 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { headRef: "user/feature", baseRefName: "main", }, - runSetup: true, + paseoHome, }); expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); - expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n"); + expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); const currentBranch = execFileSync("git", ["branch", "--show-current"], { cwd: result.worktreePath, }) @@ -494,7 +494,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { headRef: "Feature.X", baseRefName: "main", }, - runSetup: true, + paseoHome, }); @@ -540,7 +540,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/heads/main", worktreeSlug: "prefer-local-feature", - runSetup: false, + paseoHome, }); const originResult = await createLegacyWorktreeForTest({ @@ -548,7 +548,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/origin/main", worktreeSlug: "prefer-origin-feature", - runSetup: false, + paseoHome, }); @@ -598,7 +598,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/upstream/main", worktreeSlug: "fork-base-feature", - runSetup: false, + paseoHome, }); @@ -689,7 +689,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/upstream/release+hotfix", worktreeSlug: "release-hotfix-feature", - runSetup: false, + paseoHome, }); @@ -717,7 +717,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "prefer-local-fallback-feature", - runSetup: false, + paseoHome, }); @@ -731,7 +731,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "does-not-exist", worktreeSlug: "missing-base-feature", - runSetup: false, + paseoHome, }), ).rejects.toThrow("Base branch not found: does-not-exist"); @@ -755,7 +755,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "invalid-existing-branch", source: { kind: "checkout-branch", branchName: "bad..name" }, - runSetup: true, + paseoHome, }); } catch (error) { @@ -773,7 +773,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "invalid-option-like-branch", source: { kind: "checkout-branch", branchName: "-bad" }, - runSetup: true, + paseoHome, }); } catch (error) { @@ -853,6 +853,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(existsSync(result.worktreePath)).toBe(true); + await runWorktreeSetupCommands({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); // Verify setup ran and env vars were available const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); expect(setupLog).toContain(`source=${repoDir}`); @@ -886,6 +890,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { paseoHome, }); + await runWorktreeSetupCommands({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ 'greeting="hello from string setup"\necho "$greeting" > setup.log', ]); @@ -926,7 +934,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { await runWorktreeSetupCommands({ worktreePath: repoDir, branchName: "main", - cleanupOnFailure: false, runtimeEnv: { PASEO_SOURCE_CHECKOUT_PATH: repoDir, PASEO_ROOT_PATH: repoDir, @@ -1002,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ]); }); - it("does not run setup commands when runSetup=false", async () => { + it("only provisions the worktree without executing setup", async () => { const paseoConfig = { worktree: { setup: ['echo "setup ran" > setup.log'], @@ -1019,7 +1026,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "no-setup-test", - runSetup: false, + paseoHome, }); @@ -1043,7 +1050,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { const results = await runWorktreeSetupCommands({ worktreePath: repoDir, branchName: "main", - cleanupOnFailure: false, onEvent: (event) => { progressEvents.push(event); }, @@ -1061,7 +1067,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "runtime-env-port-reuse", - runSetup: false, + paseoHome, }); @@ -1083,7 +1089,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "runtime-env-port-conflict", - runSetup: false, + paseoHome, }); @@ -1117,35 +1123,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("cleans up worktree if setup command fails", async () => { - // Create paseo.json with failing setup command - const paseoConfig = { - worktree: { - setup: ["exit 1"], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { - cwd: repoDir, - }); - - const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test"); - - await expect( - createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "fail-test", - paseoHome, - }), - ).rejects.toThrow("Worktree setup command failed"); - - // Verify worktree was cleaned up - expect(existsSync(expectedWorktreePath)).toBe(false); - }); - it("reads worktree terminal specs from paseo.json with optional name", async () => { const paseoConfig = { worktree: { @@ -1278,7 +1255,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "seed-uncommitted", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" }, - runSetup: false, + paseoHome, }); @@ -1324,7 +1301,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { baseBranch: "origin/main", branchName: "feature/upstream-config", }, - runSetup: false, + paseoHome, }); @@ -1364,7 +1341,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { baseBranch: "symlink-config", branchName: "feature/symlink-config", }, - runSetup: false, + paseoHome, }); @@ -1385,7 +1362,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "no-config", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 82505dc800..4d6f50bba6 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -31,7 +31,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -50,7 +50,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } @@ -132,7 +132,7 @@ describe("paseo worktree manager", () => { cwd: repoDir, paseoHome, worktreeSlug: "fork-review", - runSetup: false, + source: { kind: "checkout-change-request", forge: "gitea", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 39b64d8137..ca921fb219 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,13 +1,6 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { - constants as fsConstants, - existsSync, - mkdirSync, - realpathSync, - rmSync, - statSync, -} from "fs"; +import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; @@ -217,7 +210,6 @@ export interface CreateWorktreeOptions { cwd: string; worktreeSlug: string; source: WorktreeSource; - runSetup: boolean; paseoHome?: string; worktreesRoot?: string; } @@ -643,7 +635,6 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< export async function runWorktreeSetupCommands(options: { worktreePath: string; branchName: string; - cleanupOnFailure: boolean; repoRootPath?: string; runtimeEnv?: WorktreeRuntimeEnv; signal?: AbortSignal; @@ -683,16 +674,6 @@ export async function runWorktreeSetupCommands(options: { results.push(result); if (result.exitCode !== 0) { - if (options.cleanupOnFailure) { - try { - await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], { - cwd: options.worktreePath, - timeout: 120_000, - }); - } catch { - rmSync(options.worktreePath, { recursive: true, force: true }); - } - } throw new WorktreeSetupError( `Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(), results, @@ -1221,7 +1202,6 @@ export const createWorktree = async ({ cwd, source, worktreeSlug, - runSetup, paseoHome, worktreesRoot, }: CreateWorktreeOptions): Promise => { @@ -1269,14 +1249,6 @@ export const createWorktree = async ({ await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - if (runSetup) { - await runWorktreeSetupCommands({ - worktreePath, - branchName: sourcePlan.branchName, - cleanupOnFailure: true, - }); - } - return { branchName: sourcePlan.branchName, worktreePath,