From 90a81462cba3a9c9e4fdbbcfb5d5b80e7b14d38e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 16 Feb 2026 11:55:16 +0700 Subject: [PATCH] fix worktree archive terminal cleanup and worktree setup --- .../daemon-e2e/git-operations.e2e.test.ts | 93 +++++++++++++++++++ packages/server/src/server/session.ts | 76 +++++++++++---- packages/server/src/utils/worktree.test.ts | 14 +++ packages/server/src/utils/worktree.ts | 12 ++- paseo.json | 5 +- 5 files changed, 178 insertions(+), 22 deletions(-) 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 cbd318ba8..d8af45ff1 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 @@ -665,5 +665,98 @@ describe("daemon E2E", () => { ); }); + describe("archivePaseoWorktree", () => { + test( + "archives worktree by running destroy commands and shutting down worktree terminals", + async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt"); + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + ], + destroy: [ + `echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + 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", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + await waitForPathExists({ + targetPath: path.join(agent.cwd, "dev-terminal.txt"), + timeoutMs: 15000, + label: "worktree terminal marker", + }); + + const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(beforeArchiveDirectories).toContain(agent.cwd); + + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: agent.cwd, + }); + expect(archive.error).toBeNull(); + expect(archive.success).toBe(true); + expect(archive.removedAgents).toContain(agent.id); + + expect(existsSync(agent.cwd)).toBe(false); + expect(existsSync(destroyMarkerPath)).toBe(true); + expect(readFileSync(destroyMarkerPath, "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 + ); + }); + }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 24a8131c4..22be1bb49 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4484,11 +4484,13 @@ export class Session { } } - await deletePaseoWorktree({ - cwd: repoRoot, - worktreePath: targetPath, - paseoHome: this.paseoHome, - }); + await this.killTerminalsUnderPath(targetPath); + + await deletePaseoWorktree({ + cwd: repoRoot, + worktreePath: targetPath, + paseoHome: this.paseoHome, + }); for (const agentId of removedAgents) { this.emit({ @@ -6428,6 +6430,56 @@ export class Session { session.send(msg.message); } + private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void { + const unsubscribe = this.terminalSubscriptions.get(terminalId); + if (unsubscribe) { + unsubscribe(); + this.terminalSubscriptions.delete(terminalId); + } + + const streamId = this.terminalStreamByTerminalId.get(terminalId); + if (typeof streamId === "number") { + this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }); + } + + this.terminalManager?.killTerminal(terminalId); + } + + private async killTerminalsUnderPath(rootPath: string): Promise { + if (!this.terminalManager) { + return; + } + + const cleanupErrors: Array<{ cwd: string; message: string }> = []; + const terminalDirectories = [...this.terminalManager.listDirectories()]; + for (const terminalCwd of terminalDirectories) { + if (!this.isPathWithinRoot(rootPath, terminalCwd)) { + continue; + } + + try { + const terminals = await this.terminalManager.getTerminals(terminalCwd); + for (const terminal of [...terminals]) { + this.killTrackedTerminal(terminal.id, { emitExit: true }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupErrors.push({ cwd: terminalCwd, message }); + this.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to clean up worktree terminals during archive" + ); + } + } + + if (cleanupErrors.length > 0) { + const details = cleanupErrors + .map((entry) => `${entry.cwd}: ${entry.message}`) + .join("; "); + throw new Error(`Failed to clean up worktree terminals during archive (${details})`); + } + } + private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -6441,19 +6493,7 @@ export class Session { return; } - // Unsubscribe first - const unsubscribe = this.terminalSubscriptions.get(msg.terminalId); - if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(msg.terminalId); - } - - const streamId = this.terminalStreamByTerminalId.get(msg.terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: true }); - } - - this.terminalManager.killTerminal(msg.terminalId); + this.killTrackedTerminal(msg.terminalId, { emitExit: true }); this.emit({ type: "kill_terminal_response", payload: { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 474a9d89a..9730f1b06 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -93,6 +93,20 @@ describe("createWorktree", () => { rmSync(varTempDir, { recursive: true, force: true }); }); + it("reports repoRoot as the repository root for paseo-owned worktrees", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "repo-root-check", + paseoHome, + }); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.repoRoot).toBe(repoDir); + }); + it("creates a worktree with a new branch", async () => { const result = await createWorktree({ branchName: "feature-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 6bcaa1c77..4da46cf90 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -667,11 +667,19 @@ function normalizePathForOwnership(input: string): string { } } +function resolveRepoRootFromGitCommonDir(commonDir: string): string { + const normalizedCommonDir = normalizePathForOwnership(commonDir); + return basename(normalizedCommonDir) === ".git" + ? dirname(normalizedCommonDir) + : normalizedCommonDir; +} + export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: { paseoHome?: string } ): Promise { const gitCommonDir = await getGitCommonDir(cwd); + const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; const resolvedCwd = normalizePathForOwnership(cwd); @@ -679,7 +687,7 @@ export async function isPaseoOwnedWorktreeCwd( if (!resolvedCwd.startsWith(resolvedRoot)) { return { allowed: false, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; @@ -692,7 +700,7 @@ export async function isPaseoOwnedWorktreeCwd( }); return { allowed, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; diff --git a/paseo.json b/paseo.json index 9d33a7dca..2e68019e0 100644 --- a/paseo.json +++ b/paseo.json @@ -2,12 +2,13 @@ "worktree": { "setup": [ "npm ci", + "npm run build --workspace=@getpaseo/relay", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ], "terminals": [ { - "name": "anti-andran dev", - "command": "npm run dev" + "name": "dev", + "command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi" } ] }