From f56c0139283028ea8b62e9ce40c1fcccad7b3efb Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:11:13 +0200 Subject: [PATCH] Open configured terminals after worktree setup (#6999) * fix(server): open worktree.terminals when a workspace is created on its own runWorktreeSetupInBackground only opened the paseo.json worktree.terminals when its caller passed runAutoTerminals, and only the fork-PR "allow setup" handler did. workspace.create, the request the New Workspace screen sends, ran worktree.setup and then skipped the terminals. Both callers want the terminals, so the flag goes away: terminals follow a successful setup on a newly created worktree. Reused worktrees stay excluded by shouldBootstrap, and the agent setup continuation never reaches this function, so nothing opens twice. Co-Authored-By: Claude Fable 5.1 * test(server): run automatic terminal regressions in CI Include workspace creation in the existing integration command, and append to the terminal marker so repeated command execution fails the regression. * test(server): make workspace fixtures portable on Windows Run the setup fixture through a command accepted by PowerShell, await its readiness, and fix the fixture Git line-ending policy. Allow Windows directory locks to settle during cleanup. * test(server): expose setup failures and await fixture cleanup Check the public setup status before terminal startup so failures distinguish setup errors from terminal creation. Use asynchronous directory removal so Windows cleanup retries leave the event loop available. * test(server): start the Windows setup service asynchronously The Windows regression starts its service but setup stays running, so terminal bootstrap is never reached. Launch the fixture service explicitly with Start-Process and retain the readiness and setup-completion assertions. --------- Co-authored-by: David Yang <9925+davidyang@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- ...rkspace-create-worktree-source.e2e.test.ts | 144 ++++++++++++++++-- .../server/src/server/worktree-bootstrap.ts | 7 +- .../server/src/server/worktree-session.ts | 37 +++-- 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index a69a2c3c2a..6e5e2448a2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", 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 b377c22374..69c1da2efc 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 @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, onTestFinished, test } from "vitest"; @@ -18,6 +19,7 @@ function createGitRepoWithBranch(): { repoDir: string; tempRoot: string } { const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-create-worktree-source-")); const repoDir = path.join(tempRoot, "repo"); execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "core.autocrlf", "false"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repoDir, stdio: "pipe", @@ -60,10 +62,134 @@ test("workspace.create worktree source forwards action=checkout + refName into t } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); +test.each([false, true])( + "workspace.create opens worktree.terminals (setup service: %s)", + async (withSetupService) => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + ...(withSetupService + ? { + // Launch the Windows service asynchronously so it outlives + // the setup command instead of holding setup open. + setup: + process.platform === "win32" + ? "Start-Process -FilePath node -ArgumentList 'setup.cjs','serve' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden; while (!(Test-Path setup-pid.txt)) { Start-Sleep -Milliseconds 25 }" + : "node setup.cjs", + } + : {}), + terminals: [ + { name: "preview", command: "echo paseo-auto-terminal >> terminal-marker.txt" }, + ], + }, + }), + ); + writeFileSync( + path.join(repoDir, "setup.cjs"), + ` +const fs = require("node:fs"); +if (process.argv[2] === "serve") { + require("node:net").createServer().listen(Number(process.env.PASEO_WORKTREE_PORT), "127.0.0.1", () => { + fs.writeFileSync("setup-pid.txt", String(process.pid)); + process.send?.("ready"); + }); +} else { + const child = require("node:child_process").spawn(process.execPath, [__filename, "serve"], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + child.once("message", () => { child.disconnect(); child.unref(); }); +} +`, + ); + execFileSync("git", ["add", "paseo.json", "setup.cjs"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + stdio: "pipe", + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + let workspaceCwd: string | undefined; + try { + await client.connect(); + + // This is the request the New Workspace screen sends: the workspace is + // created on its own, with no agent in the same request. + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + branchName: "feature/auto-terminals", + worktreeSlug: "feature-auto-terminals", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + const workspace = result.workspace; + if (!workspace) { + throw new Error("Expected a created workspace"); + } + + workspaceCwd = workspace.workspaceDirectory; + if (withSetupService) { + await expect + .poll(() => existsSync(path.join(workspaceCwd!, "setup-pid.txt")), { timeout: 30000 }) + .toBe(true); + } + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(workspace.id)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ status: "completed", error: null }); + await expect + .poll( + async () => + ( + await client.listTerminals(workspace.workspaceDirectory, undefined, { + workspaceId: workspace.id, + }) + ).terminals.map((terminal) => terminal.name), + { timeout: 30000 }, + ) + .toContain("preview"); + await expect + .poll( + () => { + const marker = path.join(workspaceCwd!, "terminal-marker.txt"); + return existsSync(marker) ? readFileSync(marker, "utf8").trim() : null; + }, + { timeout: 10000 }, + ) + .toBe("paseo-auto-terminal"); + expect( + (await client.listTerminals(workspaceCwd, undefined, { workspaceId: workspace.id })) + .terminals, + ).toHaveLength(1); + } finally { + const pidFile = workspaceCwd && path.join(workspaceCwd, "setup-pid.txt"); + if (pidFile && existsSync(pidFile)) { + process.kill(Number(readFileSync(pidFile, "utf8"))); + } + await client.close().catch(() => undefined); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 180000, +); + test("existing branch changes remain visible after archive and restore", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); @@ -74,7 +200,7 @@ test("existing branch changes remain visible after archive and restore", async ( onTestFinished(async () => { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); runGit(repoDir, "switch", "feature/existing-branch"); commitFile(repoDir, "feature.txt", "feature change\n"); @@ -134,7 +260,7 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -168,7 +294,7 @@ test("workspace.create accepts a Git-valid branch-off name outside Paseo slug sy } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -210,7 +336,7 @@ test("workspace.create always creates a new worktree when the slug is already oc } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -255,7 +381,7 @@ test("workspace.create suffixes past an occupied detached worktree", async () => } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -297,7 +423,7 @@ test("workspace.create suffixes an occupied checkout branch", async () => { } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -340,7 +466,7 @@ async function createRestorableWorkspace(baseRef: string) { onTestFinished(async () => { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); createDivergentBases(repoDir); await client.connect(); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 8e69908f59..4bd49b1c2d 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -593,13 +593,10 @@ export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; workspaceCwd: string; + runtimeEnv: WorktreeRuntimeEnv; terminalManager: TerminalManager | null; logger?: Logger; }): Promise { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktree.worktreePath, - branchName: options.worktree.branchName, - }); await runWorktreeTerminalBootstrap( { agentId: options.workspaceId, @@ -610,7 +607,7 @@ export async function runWorktreeAutoTerminals(options: { appendTimelineItem: async () => true, logger: options.logger, }, - runtimeEnv, + options.runtimeEnv, ); } diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cdb0119b64..82f0113bd5 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -817,7 +817,6 @@ export async function handleWorkspaceSetupRunRequest( slug: basename(worktree.worktreePath), worktreePath: worktree.worktreePath, workspaceCwd: workspace.cwd, - runAutoTerminals: true, }, signal, ), @@ -857,7 +856,6 @@ export async function runWorktreeSetupInBackground( slug: string; worktreePath: string; workspaceCwd?: string; - runAutoTerminals?: boolean; }, signal?: AbortSignal, ): Promise { @@ -898,18 +896,18 @@ export async function runWorktreeSetupInBackground( } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + repoRootPath: options.repoRoot, + }); + dependencies.terminalManager?.registerCwdEnv({ + cwd: workspaceCwd, + env: runtimeEnv, + }); if (setupCommands.length === 0) { emitSetupProgress("completed", null); } else { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - repoRootPath: options.repoRoot, - }); - dependencies.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -924,15 +922,14 @@ export async function runWorktreeSetupInBackground( }); emitSetupProgress("completed", null); } - if (options.runAutoTerminals) { - await runWorktreeAutoTerminals({ - workspaceId, - worktree, - workspaceCwd, - terminalManager: dependencies.terminalManager, - logger: dependencies.sessionLogger, - }); - } + await runWorktreeAutoTerminals({ + workspaceId, + worktree, + workspaceCwd, + runtimeEnv, + terminalManager: dependencies.terminalManager, + logger: dependencies.sessionLogger, + }); } } catch (error) { if (error instanceof WorktreeSetupError) {