diff --git a/docs/data-model.md b/docs/data-model.md index 177a0d126..b2ff26ba4 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -20,6 +20,9 @@ checkout root. They intentionally differ for an exact subproject inside a worktr restore, branch auto-name, and descriptor flows consume those persisted facts rather than rediscovering ownership from a directory that may already be gone. Reconciliation may refresh mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. +Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing +`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing +checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. 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 7531b03fc..25f616922 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -216,8 +216,6 @@ export class CreateAgentLifecycleDispatch { }, { scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId }, - repoRoot: createdWorktree.repoRoot ?? ownership.repoRoot ?? null, - paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, requestId: randomUUID(), }, ); 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 6a6f5d7db..9f83c1ce4 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 @@ -486,8 +486,6 @@ describe("archiveIfSafe", () => { }), { scope: { kind: "workspace", workspaceId: "ws-auto-archive" }, - repoRoot: "/tmp/repo", - paseoWorktreesBaseRoot: undefined, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index bd6f8d720..126f615bc 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -138,8 +138,6 @@ export async function archiveIfSafe(input: { }, { scope: { kind: "workspace", workspaceId }, - repoRoot: ownership.repoRoot ?? null, - paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 367f1fee5..09033626a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -886,6 +886,7 @@ export async function createPaseoDaemon( kind: workspace.kind, worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, + mainRepoRoot: workspace.mainRepoRoot, })); }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { @@ -1057,7 +1058,7 @@ export async function createPaseoDaemon( await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]); return result; }; - const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => { + const archiveScheduleWorkspaceExternal = async (workspaceId: string) => { await archiveByScope( { paseoHome: config.paseoHome, @@ -1084,8 +1085,6 @@ export async function createPaseoDaemon( }, { scope: { kind: "workspace", workspaceId }, - repoRoot, - paseoWorktreesBaseRoot: config.worktreesRoot, requestId: "schedule-run-finish", }, ); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index c94fec985..a7fad87be 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -138,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer }, { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "schedule-service-test", }, ); @@ -242,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi }, { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "schedule-service-test", }, ); @@ -1906,7 +1904,7 @@ describe("ScheduleService", () => { ], })); - const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + const archiveCalls: string[] = []; now = new Date("2026-01-01T00:10:00.000Z"); const service2 = createScheduleService({ paseoHome: tempDir, @@ -1916,13 +1914,13 @@ describe("ScheduleService", () => { providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), - archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { - archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + archiveWorkspace: async (archivedWorkspaceId) => { + archiveCalls.push(archivedWorkspaceId); }, }); await service2.start(); - expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + expect(archiveCalls).toEqual([workspaceId]); const inspected = await service2.inspect(created.id); expect(inspected.runs[0]).toMatchObject({ status: "failed", @@ -1974,7 +1972,7 @@ describe("ScheduleService", () => { ], })); - const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + const archiveCalls: string[] = []; now = new Date("2026-01-01T00:10:00.000Z"); const service2 = createScheduleService({ paseoHome: tempDir, @@ -1984,13 +1982,13 @@ describe("ScheduleService", () => { providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), - archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { - archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + archiveWorkspace: async (archivedWorkspaceId) => { + archiveCalls.push(archivedWorkspaceId); }, }); await service2.start(); - expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + expect(archiveCalls).toEqual([workspaceId]); const inspected = await service2.inspect(created.id); expect(inspected.runs[0]).toMatchObject({ status: "failed", diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 36f3edaa4..d55ecb464 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -224,7 +224,7 @@ export interface ScheduleServiceOptions { createPaseoWorktreeWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; - archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; + archiveWorkspace: (workspaceId: string) => Promise; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; } @@ -241,7 +241,7 @@ export class ScheduleService { private readonly createPaseoWorktreeWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; - private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; + private readonly archiveWorkspace: (workspaceId: string) => Promise; private readonly now: () => Date; private readonly runner: ( schedule: StoredSchedule, @@ -579,7 +579,6 @@ export class ScheduleService { private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise { const interruptedWorkspaces: Array<{ workspaceId: string; - repoRoot: string; agentId: string | null; runId: string; }> = []; @@ -601,7 +600,6 @@ export class ScheduleService { ) { interruptedWorkspaces.push({ workspaceId: runningRun.workspaceId, - repoRoot: updated.target.config.cwd, agentId: runningRun.agentId, runId: runningRun.id, }); @@ -639,7 +637,7 @@ export class ScheduleService { return; } try { - await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot); + await this.archiveWorkspace(interruptedWorkspace.workspaceId); } catch (error) { this.logger.warn( { @@ -930,7 +928,7 @@ export class ScheduleService { shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish }) ) { try { - await this.archiveWorkspace(workspace.workspaceId, config.cwd); + await this.archiveWorkspace(workspace.workspaceId); } catch (error) { this.logger.warn( { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9c19e4a7a..06f698e76 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { basename, resolve, sep } from "path"; +import { resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { @@ -179,7 +179,7 @@ import { matchesAgentUpdatesFilter, type AgentUpdatesService, } from "./session/agent-updates/agent-updates-service.js"; -import { createRealpathAwarePathMatcher, expandTilde } from "../utils/path.js"; +import { expandTilde } from "../utils/path.js"; import { searchDirectoryEntries, WORKSPACE_SEARCH_HIDDEN_DIRECTORIES, @@ -225,22 +225,12 @@ import { handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; -import { - WorktreeRequestError, - toWorktreeRequestError, - toWorktreeWireError, -} from "./worktree-errors.js"; +import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { createProjectDirectory, ProjectDirectoryRequestError, } from "./project-directory-service.js"; -import { - type WorktreeConfig, - createWorktree, - isPaseoOwnedWorktreeCwd, - mapWorkspaceCwdToWorktree, -} from "../utils/worktree.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -682,10 +672,11 @@ export class Session { logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), getProject: (projectId) => this.projectRegistry.get(projectId), isDirectory: (path) => this.filesystem.isDirectory(path), - recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace), unarchiveWorkspace: async (workspace) => { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, @@ -3985,78 +3976,6 @@ export class Session { }; } - private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise { - const branch = workspace.branch; - if (!branch) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Workspace ${workspace.workspaceId} has no branch to restore`, - }); - } - const project = await this.projectRegistry.get(workspace.projectId); - if (!project) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`, - }); - } - const projectRootExists = await this.filesystem - .isDirectory(project.rootPath) - .catch(() => false); - if (!projectRootExists) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`, - }); - } - - // Archiving through the default path (scope "workspace", worktreePath only) - // resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/ - // `prune` is skipped and the admin registration survives — pinning the - // branch as "already checked out". Prune here frees any stale registration - // whose working dir is missing (a no-op for live worktrees) so the recreate - // below succeeds regardless of how the worktree was archived. - try { - await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 }); - } catch { - // not critical; git will prune lazily - } - - const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }); - const previousWorktreePath = - workspace.worktreeRoot ?? - (ownership.allowed ? (ownership.worktreePath ?? workspace.cwd) : workspace.cwd); - let result: WorktreeConfig; - try { - result = await createWorktree({ - cwd: project.rootPath, - worktreeSlug: basename(previousWorktreePath), - source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }); - } catch (error) { - throw toWorktreeRequestError(error); - } - - const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({ - sourceWorktreePath: previousWorktreePath, - workspaceCwd: workspace.cwd, - targetWorktreePath: result.worktreePath, - }); - if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`, - }); - } - await mkdir(recreatedWorkspacePath, { recursive: true }); - } - private async restoreWorkspaceAndEmit(workspaceId: string): Promise { await this.workspaceRecovery.restore(workspaceId); const workspace = await this.workspaceRegistry.get(workspaceId); @@ -4130,6 +4049,7 @@ export class Session { kind: workspace.kind, worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, + mainRepoRoot: workspace.mainRepoRoot, })); } @@ -5251,11 +5171,6 @@ export class Session { throw new Error(`Workspace not found: ${request.workspaceId}`); } - const gitSnapshot = await this.workspaceGitService - .getSnapshot(existing.cwd) - .catch(() => null); - const repoRoot = gitSnapshot?.git?.repoRoot ?? null; - await archiveByScope( { paseoHome: this.paseoHome, @@ -5278,8 +5193,6 @@ export class Session { }, { scope: { kind: "workspace", workspaceId: existing.workspaceId }, - repoRoot, - paseoWorktreesBaseRoot: this.worktreesRoot, requestId: request.requestId, }, ); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 0962bcdf3..0a51f0c6d 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -39,7 +39,6 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; -import { WorktreeRequestError } from "./worktree-errors.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; @@ -129,7 +128,6 @@ interface SessionTestAccess { agentUpdates: AgentUpdatesService; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; - recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise; reconcileActiveWorkspaceRecords(...args: unknown[]): Promise>; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -5393,127 +5391,6 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () = rmSync(tempDir, { recursive: true, force: true }); }); -test("recreateArchivedWorktree restores an archived exact subdirectory", async () => { - const { tempDir, repoDir } = createRecreateWorktreeRepo(); - const sourceSubdirectory = path.join(repoDir, "packages", "app"); - mkdirSync(sourceSubdirectory, { recursive: true }); - writeFileSync(path.join(sourceSubdirectory, "README.md"), "app\n"); - execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); - const branch = "feature/subdirectory"; - execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); - - const worktreesRoot = path.join(tempDir, "worktrees"); - const paseoHome = path.join(tempDir, "paseo-home"); - const created = await createWorktree({ - cwd: repoDir, - worktreeSlug: "subdirectory", - source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, - paseoHome, - worktreesRoot, - }); - const worktreeRoot = realpathSync(created.worktreePath); - const workspaceCwd = path.join(worktreeRoot, "packages", "app"); - rmSync(worktreeRoot, { recursive: true, force: true }); - execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); - - const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot }); - const project = createPersistedProjectRecord({ - projectId: repoDir, - rootPath: repoDir, - kind: "git", - displayName: "worktree-project", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }); - session.projectRegistry.get = async () => project; - - await session.recreateArchivedWorktree( - createPersistedWorkspaceRecord({ - workspaceId: "ws-subdirectory-recreate", - projectId: project.projectId, - cwd: workspaceCwd, - kind: "worktree", - branch, - worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: repoDir, - displayName: branch, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }), - ); - - expect(existsSync(worktreeRoot)).toBe(true); - expect(existsSync(workspaceCwd)).toBe(true); - rmSync(tempDir, { recursive: true, force: true }); -}); - -test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => { - const { tempDir, repoDir } = createRecreateWorktreeRepo(); - const branch = "feature/keep"; - execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); - - const worktreesRoot = path.join(tempDir, "worktrees"); - const paseoHome = path.join(tempDir, "paseo-home"); - const created = await createWorktree({ - cwd: repoDir, - worktreeSlug: "keep", - source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, - paseoHome, - worktreesRoot, - }); - const worktreePath = realpathSync(created.worktreePath); - - const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot }); - const projects = new Map>(); - const workspaces = new Map>(); - const workspaceId = "ws-missing-root"; - const projectId = repoDir; - const missingRoot = path.join(tempDir, "does-not-exist"); - projects.set( - projectId, - createPersistedProjectRecord({ - projectId, - rootPath: missingRoot, - kind: "git", - displayName: "worktree-project", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }), - ); - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId, - projectId, - cwd: worktreePath, - kind: "worktree", - branch, - displayName: branch, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }); - workspaces.set(workspaceId, workspaceRecord); - - session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; - session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; - session.filesystem.isDirectory = async (target: string) => - existsSync(target) && statSync(target).isDirectory(); - - await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf( - WorktreeRequestError, - ); - // Guard fires before createWorktree, so archivedAt is untouched. - expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); - - rmSync(tempDir, { recursive: true, force: true }); -}); - test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); 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 2d04d9fc9..992cb90c7 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 @@ -1,4 +1,19 @@ -import { describe, expect, test } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createWorktree } from "../../../utils/worktree.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -8,15 +23,23 @@ import { import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js"; const NOW = "2026-07-11T10:12:30.752Z"; +const tempDirectories: string[] = []; -function createProject(): PersistedProjectRecord { +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createProject(overrides: Partial = {}): PersistedProjectRecord { return createPersistedProjectRecord({ - projectId: "/repo", - rootPath: "/repo", + projectId: "/project", + rootPath: "/project", kind: "git", - displayName: "repo", + displayName: "project", createdAt: NOW, updatedAt: NOW, + ...overrides, }); } @@ -25,12 +48,15 @@ function createWorkspace( ): PersistedWorkspaceRecord { return createPersistedWorkspaceRecord({ workspaceId: "wks_15a1b5630ebaab33", - projectId: "/repo", + projectId: "/project", cwd: "/worktrees/trigger-1525443412986298439", kind: "worktree", displayName: "diagnose-repro-tdd", - title: "Codex TDD reproduction", + title: "TDD reproduction", branch: "diagnose-repro-tdd", + worktreeRoot: "/worktrees/trigger-1525443412986298439", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", createdAt: NOW, updatedAt: NOW, archivedAt: NOW, @@ -42,55 +68,53 @@ function createHarness(input?: { workspace?: PersistedWorkspaceRecord | null; project?: PersistedProjectRecord | null; directories?: string[]; - recreate?: (workspace: PersistedWorkspaceRecord) => Promise; + paseoHome?: string; + worktreesRoot?: string; }) { const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace; const project = input?.project === undefined ? createProject() : input.project; const directories = new Set(input?.directories ?? ["/repo"]); const unarchived: string[] = []; - const recreated: string[] = []; const service = createWorkspaceRecoveryService({ + paseoHome: input?.paseoHome ?? "/paseo-home", + worktreesRoot: input?.worktreesRoot ?? "/worktrees", getWorkspace: async (workspaceId) => workspace?.workspaceId === workspaceId ? workspace : null, getProject: async (projectId) => (project?.projectId === projectId ? project : null), isDirectory: async (path) => directories.has(path), - recreateWorktree: async (record) => { - recreated.push(record.workspaceId); - await input?.recreate?.(record); - }, unarchiveWorkspace: async (record) => { unarchived.push(record.workspaceId); }, }); - return { service, recreated, unarchived }; + return { service, unarchived }; } describe("workspace recovery", () => { - test("authoritatively describes the archived missing worktree from the failed cloud run", async () => { - const { service, recreated, unarchived } = createHarness(); + test("describes a missing archived worktree from persisted placement", async () => { + const { service, unarchived } = createHarness(); await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({ kind: "recoverable", workspaceId: "wks_15a1b5630ebaab33", - workspaceName: "Codex TDD reproduction", + workspaceName: "TDD reproduction", action: "restore", branch: "diagnose-repro-tdd", }); - expect(recreated).toEqual([]); expect(unarchived).toEqual([]); }); - test("describes an archived workspace whose directory remains as unarchivable", async () => { + test("unarchives an archived workspace whose exact directory remains", async () => { const workspace = createWorkspace({ kind: "directory", branch: null }); - const { service } = createHarness({ + const { service, unarchived } = createHarness({ workspace, - directories: ["/repo", workspace.cwd], + directories: [workspace.cwd], }); - await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ - kind: "recoverable", + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, action: "unarchive", }); + expect(unarchived).toEqual([workspace.workspaceId]); }); test("does not offer recovery for a missing non-worktree directory", async () => { @@ -105,27 +129,104 @@ describe("workspace recovery", () => { }); }); - test("keeps the workspace archived when recreation fails so restore can be retried", async () => { - let attempts = 0; - const { service, recreated, unarchived } = createHarness({ - recreate: async () => { - attempts += 1; - if (attempts === 1) { - throw new Error("git branch diagnose-repro-tdd is unavailable"); - } + test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => { + const { tempDir, repoDir } = createGitRepository(); + const branch = "feature/mixed-project"; + const sourceSubdirectory = join(repoDir, "packages", "app"); + mkdirSync(sourceSubdirectory, { recursive: true }); + writeFileSync(join(sourceSubdirectory, "README.md"), "app\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); + + const paseoHome = join(tempDir, "paseo-home"); + const worktreesRoot = join(tempDir, "worktrees"); + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "mixed-project", + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome, + worktreesRoot, + }); + const worktreeRoot = realpathSync(created.worktreePath); + const workspaceCwd = join(worktreeRoot, "packages", "app"); + rmSync(worktreeRoot, { recursive: true, force: true }); + execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); + + const projectRoot = join(tempDir, "explicit-non-git-project"); + mkdirSync(projectRoot); + const project = createProject({ + projectId: "explicit-non-git-project", + rootPath: projectRoot, + kind: "non_git", + }); + const workspace = createWorkspace({ + workspaceId: "ws-mixed-project-recreate", + projectId: project.projectId, + cwd: workspaceCwd, + branch, + worktreeRoot, + mainRepoRoot: repoDir, + }); + const unarchived: string[] = []; + const service = createWorkspaceRecoveryService({ + paseoHome, + worktreesRoot, + getWorkspace: async (workspaceId) => + workspaceId === workspace.workspaceId ? workspace : null, + getProject: async (projectId) => (projectId === project.projectId ? project : null), + isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(), + unarchiveWorkspace: async (record) => { + unarchived.push(record.workspaceId); }, }); - await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow( - "git branch diagnose-repro-tdd is unavailable", - ); - expect(unarchived).toEqual([]); - - await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({ - workspaceId: "wks_15a1b5630ebaab33", + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, action: "restore", }); - expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]); - expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]); + expect(existsSync(worktreeRoot)).toBe(true); + expect(existsSync(workspaceCwd)).toBe(true); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + + test("keeps the workspace archived when its persisted source repository is missing", async () => { + const workspace = createWorkspace({ mainRepoRoot: "/missing-source" }); + const { service, unarchived } = createHarness({ + workspace, + directories: ["/project"], + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({ + kind: "unavailable", + workspaceId: workspace.workspaceId, + reason: "project_directory_missing", + message: "The source repository needed to restore this worktree no longer exists.", + }); + await expect(service.restore(workspace.workspaceId)).rejects.toThrow( + "The source repository needed to restore this worktree no longer exists.", + ); + expect(unarchived).toEqual([]); }); }); + +function createGitRepository(): { tempDir: string; repoDir: string } { + const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-")); + tempDirectories.push(tempDir); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(join(repoDir, "README.md"), "initial\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); + return { tempDir, repoDir }; +} 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 31a5f0415..edb1b9a2e 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 @@ -1,3 +1,14 @@ +import { mkdir } from "node:fs/promises"; +import { basename } from "node:path"; + +import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; +import { runGitCommand } from "../../../utils/run-git-command.js"; +import { + createWorktree, + isPaseoOwnedWorktreeCwd, + mapWorkspaceCwdToWorktree, +} from "../../../utils/worktree.js"; +import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js"; import { resolveWorkspaceDisplayName, type PersistedProjectRecord, @@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService { restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>; } +type RecoveryPlan = + | { + kind: "unarchive"; + state: Extract; + workspace: PersistedWorkspaceRecord; + } + | { + kind: "restore"; + state: Extract; + workspace: PersistedWorkspaceRecord; + sourceRepoRoot: string; + }; + +type UnavailableRecoveryState = Extract; + export function createWorkspaceRecoveryService(deps: { + paseoHome: string; + worktreesRoot?: string; getWorkspace: (workspaceId: string) => Promise; getProject: (projectId: string) => Promise; isDirectory: (path: string) => Promise; - recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise; unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise; }): WorkspaceRecoveryService { - async function inspect(workspaceId: string): Promise { + async function resolveRecovery( + workspaceId: string, + ): Promise { const workspace = await deps.getWorkspace(workspaceId); if (!workspace) { return { @@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: { } if (await deps.isDirectory(workspace.cwd)) { - return { - kind: "recoverable", - workspaceId, - workspaceName: resolveWorkspaceDisplayName(workspace), - action: "unarchive", - branch: workspace.branch, - }; + return createRecoveryPlan({ action: "unarchive", workspace }); } if (workspace.kind !== "worktree") { @@ -94,42 +117,130 @@ export function createWorkspaceRecoveryService(deps: { message: "The archived worktree has no branch recorded, so it cannot be restored.", }; } - if (!(await deps.isDirectory(project.rootPath))) { + + // COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110 + // lack placement ownership; remove the project-root fallback after 2027-01-17. + const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath; + if (!(await deps.isDirectory(sourceRepoRoot))) { return { kind: "unavailable", workspaceId, reason: "project_directory_missing", - message: "The project directory needed to restore this worktree no longer exists.", + message: "The source repository needed to restore this worktree no longer exists.", }; } - return { - kind: "recoverable", - workspaceId, - workspaceName: resolveWorkspaceDisplayName(workspace), - action: "restore", - branch: workspace.branch, - }; + return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot }); + } + + async function inspect(workspaceId: string): Promise { + const resolved = await resolveRecovery(workspaceId); + return resolved.kind === "unavailable" ? resolved : resolved.state; } async function restore( workspaceId: string, ): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> { - const state = await inspect(workspaceId); - if (state.kind === "unavailable") { - throw new Error(state.message); + const resolved = await resolveRecovery(workspaceId); + if (resolved.kind === "unavailable") { + throw new Error(resolved.message); } - const workspace = await deps.getWorkspace(workspaceId); - if (!workspace?.archivedAt) { - throw new Error("The archived workspace changed before it could be recovered."); + if (resolved.kind === "restore") { + await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot); } - if (state.action === "restore") { - await deps.recreateWorktree(workspace); + await deps.unarchiveWorkspace(resolved.workspace); + return { workspaceId, action: resolved.kind }; + } + + async function recreateArchivedWorktree( + workspace: PersistedWorkspaceRecord, + sourceRepoRoot: string, + ): Promise { + const branch = workspace.branch; + if (!branch) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Workspace ${workspace.workspaceId} has no branch to restore`, + }); } - await deps.unarchiveWorkspace(workspace); - return { workspaceId, action: state.action }; + + try { + await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 }); + } catch { + // A stale worktree registration is not guaranteed; creation reports any real conflict. + } + + let previousWorktreePath = workspace.worktreeRoot; + if (!previousWorktreePath) { + // COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110 + // lack durable backing placement; remove filesystem discovery after 2027-01-17. + const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, { + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + previousWorktreePath = ownership.allowed + ? (ownership.worktreePath ?? workspace.cwd) + : workspace.cwd; + } + + let recreatedWorktreePath: string; + try { + const result = await createWorktree({ + cwd: sourceRepoRoot, + worktreeSlug: basename(previousWorktreePath), + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + recreatedWorktreePath = result.worktreePath; + } catch (error) { + throw toWorktreeRequestError(error); + } + + const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({ + sourceWorktreePath: previousWorktreePath, + workspaceCwd: workspace.cwd, + targetWorktreePath: recreatedWorktreePath, + }); + if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`, + }); + } + await mkdir(recreatedWorkspacePath, { recursive: true }); } return { inspect, restore }; } + +function createRecoveryPlan( + input: + | { action: "unarchive"; workspace: PersistedWorkspaceRecord } + | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string }, +): RecoveryPlan { + const state = { + kind: "recoverable" as const, + workspaceId: input.workspace.workspaceId, + workspaceName: resolveWorkspaceDisplayName(input.workspace), + branch: input.workspace.branch, + }; + if (input.action === "restore") { + return { + kind: input.action, + state: { ...state, action: input.action }, + workspace: input.workspace, + sourceRepoRoot: input.sourceRepoRoot, + }; + } + return { + kind: input.action, + state: { + ...state, + action: input.action, + }, + workspace: input.workspace, + }; +} diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 2d3552d99..04a4bf20d 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino, { type Logger } from "pino"; @@ -205,7 +205,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId }, - repoRoot: repoDir, requestId: "req-last-ref-workspace", }, ); @@ -234,7 +233,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId: workspaceA }, - repoRoot: repoDir, requestId: "req-sibling-workspace", }, ); @@ -277,7 +275,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId: sourceWorkspaceId }, - repoRoot: repoDir, requestId: "req-subdirectory-sibling", }, ); @@ -320,7 +317,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId }, - repoRoot: repoDir, requestId: "req-subdirectory-target", }, ); @@ -332,6 +328,60 @@ describe("archiveByScope", () => { expect(existsSync(worktree.worktreePath)).toBe(true); }); + test("workspace scope runs teardown from the exact nested workspace before deleting its worktree", async () => { + const { tempDir, repoDir } = createGitRepo(); + const nestedRelative = path.join("packages", "app"); + const sourceNested = path.join(repoDir, nestedRelative); + mkdirSync(sourceNested, { recursive: true }); + writeFileSync( + path.join(sourceNested, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: [ + "node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/nested-teardown.log', process.cwd())\"", + ], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "nested teardown"], { + cwd: repoDir, + stdio: "pipe", + }); + + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "nested-teardown"); + const workspaceCwd = path.join(worktree.worktreePath, nestedRelative); + const workspaceId = "ws-nested-teardown"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: workspaceCwd, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + ], + }), + { + scope: { kind: "workspace", workspaceId }, + requestId: "req-nested-teardown", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [workspaceId], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect(readFileSync(path.join(repoDir, "nested-teardown.log"), "utf8")).toBe(workspaceCwd); + }); + test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -371,7 +421,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-worktree-scope", }, ); @@ -396,7 +445,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "req-local-checkout", }, ); @@ -432,7 +480,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-partial-failure", }); @@ -457,7 +504,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: "ws-does-not-exist" }, - repoRoot: null, requestId: "req-unknown-workspace", }); @@ -482,7 +528,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-zero-records", }, ); @@ -562,7 +607,6 @@ describe("archiveByScope", () => { await archiveByScope(deps, { scope: { kind: "workspace", workspaceId }, - repoRoot: repoDir, requestId: "req-lifecycle", }); @@ -616,7 +660,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: targetWorkspaceId }, - repoRoot: repoDir, requestId: "req-snapshot-scope", }); @@ -650,7 +693,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-worktree-scope-n3", }, ); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 1c776a618..a69c882f4 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -16,18 +16,14 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; -export interface ActiveWorkspaceRef { - workspaceId: string; - cwd: string; - kind?: "local_checkout" | "worktree" | "directory"; - worktreeRoot?: string | null; - isPaseoOwnedWorktree?: boolean; -} +export type ActiveWorkspaceRef = Pick< + PersistedWorkspaceRecord, + "workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot" +>; export interface ArchiveDependencies { paseoHome?: string; - // Base directory that may hold worktrees across repositories. Used as a fallback - // when the request does not supply a per-repo root. + // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; workspaceGitService: Pick; @@ -68,15 +64,22 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; - repoRoot: string | null; - // Per-repository worktree root, used to remove the actual directory. - repoWorktreesRoot?: string; - // Base directory that may hold worktrees across repositories; falls back to the - // dependency's base root for ownership checks and path resolution. - paseoWorktreesBaseRoot?: string; requestId: string; } +interface BackingDirectory { + path: string; + isPaseoOwnedWorktree: boolean; + mainRepoRoot: string | null; + paseoWorktreesRoot: string | null; +} + +interface ArchiveTarget { + backing: BackingDirectory | null; + teardownCwd: string | null; + workspaceIds: string[]; +} + export async function resolveWorkspaceIdAtPath( dependencies: Pick, targetPath: string, @@ -91,18 +94,15 @@ export async function resolveWorkspaceIdAtPath( return dependencies.findWorkspaceIdForCwd(targetPath); } -// THE single archive entry. Resolves the in-scope record set, tears each down +// Resolves the in-scope record set, tears each down // (agents + terminals + record), then removes the backing directory iff it is // Paseo-owned AND no active workspace still references it. export async function archiveByScope( dependencies: ArchiveDependencies, request: ArchiveByScopeRequest, ): Promise { - const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets( - dependencies, - request.scope, - request.paseoWorktreesBaseRoot, - ); + const target = await resolveArchiveTarget(dependencies, request.scope); + const targetWorkspaceIds = target.workspaceIds; if (targetWorkspaceIds.length > 0) { dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString()); @@ -121,25 +121,25 @@ export async function archiveByScope( request.requestId, ); - if (request.repoRoot) { + if (target.backing?.mainRepoRoot) { try { - await dependencies.workspaceGitService.getSnapshot(request.repoRoot, { + await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, { force: true, reason: "archive-worktree", }); } catch (error) { dependencies.sessionLogger?.warn( - { err: error, cwd: request.repoRoot, requestId: request.requestId }, + { err: error, cwd: target.backing.mainRepoRoot, requestId: request.requestId }, "Failed to force-refresh workspace git snapshot after archiving", ); } } - if (targetDir !== null) { + if (target.backing !== null) { removedDirectory = await maybeRemoveDirectory( dependencies, request, - targetDir, + target, archivedWorkspaceIds, ); } @@ -157,11 +157,10 @@ export async function archiveByScope( } } -async function resolveArchiveTargets( +async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, - paseoWorktreesBaseRoot?: string, -): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> { +): Promise { const activeWorkspaces = await dependencies.listActiveWorkspaces(); if (scope.kind === "workspace") { @@ -172,66 +171,95 @@ async function resolveArchiveTargets( { workspaceId }, "Workspace not found for archive-by-scope; skipping", ); - return { targetDir: null, targetWorkspaceIds: [] }; + return { backing: null, teardownCwd: null, workspaceIds: [] }; } return { - targetDir: await resolveWorkspaceBackingDirectory( - record, - dependencies, - paseoWorktreesBaseRoot, - ), - targetWorkspaceIds: [workspaceId], + backing: await resolveWorkspaceBackingDirectory(record, dependencies), + teardownCwd: record.cwd, + workspaceIds: [workspaceId], }; } - let targetPath = scope.targetPath; - targetPath = await resolveBackingWorktreeDirectory( - targetPath, - dependencies, - paseoWorktreesBaseRoot, - ); - const targetDir = resolve(targetPath); - const matchesTargetDir = createRealpathAwarePathMatcher(targetDir); - const targetWorkspaceIds = ( + const backing = await resolveBackingDirectory(scope.targetPath, dependencies); + const matchesBackingDirectory = createRealpathAwarePathMatcher(backing.path); + const targetWorkspaces = ( await Promise.all( activeWorkspaces.map(async (workspace) => { - const backingDirectory = await resolveWorkspaceBackingDirectory( - workspace, - dependencies, - paseoWorktreesBaseRoot, - ); - return matchesTargetDir(backingDirectory) ? workspace.workspaceId : null; + const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies); + return matchesBackingDirectory(backingDirectory.path) ? workspace : null; }), ) - ).filter((workspaceId): workspaceId is string => workspaceId !== null); - return { targetDir, targetWorkspaceIds }; + ).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null); + const exactTarget = createRealpathAwarePathMatcher(scope.targetPath); + const teardownWorkspace = targetWorkspaces.find((workspace) => exactTarget(workspace.cwd)); + const persistedMainRepoRoot = targetWorkspaces.find( + (workspace) => workspace.mainRepoRoot, + )?.mainRepoRoot; + return { + backing: { + ...backing, + mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, + }, + teardownCwd: teardownWorkspace?.cwd ?? scope.targetPath, + workspaceIds: targetWorkspaces.map((workspace) => workspace.workspaceId), + }; } async function resolveWorkspaceBackingDirectory( workspace: ActiveWorkspaceRef, dependencies: Pick, - paseoWorktreesBaseRoot?: string, -): Promise { - if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot) { - return resolve(workspace.worktreeRoot); +): Promise { + if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { + return { + path: resolve(workspace.worktreeRoot), + isPaseoOwnedWorktree: true, + mainRepoRoot: workspace.mainRepoRoot, + paseoWorktreesRoot: null, + }; } - return resolveBackingWorktreeDirectory(workspace.cwd, dependencies, paseoWorktreesBaseRoot); + if (workspace.kind !== "worktree") { + return { + path: resolve(workspace.cwd), + isPaseoOwnedWorktree: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } + + // COMPAT(archiveMissingWorkspacePlacement): worktree records created before v0.1.110 + // lack durable backing ownership; remove filesystem discovery after 2027-01-17. + const backing = await resolveBackingDirectory( + workspace.worktreeRoot ?? workspace.cwd, + dependencies, + ); + return { ...backing, mainRepoRoot: workspace.mainRepoRoot ?? backing.mainRepoRoot }; } -async function resolveBackingWorktreeDirectory( +async function resolveBackingDirectory( cwd: string, dependencies: Pick, - paseoWorktreesBaseRoot?: string, -): Promise { +): Promise { const options = { paseoHome: dependencies.paseoHome, - worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + worktreesRoot: dependencies.paseoWorktreesBaseRoot, }; const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(cwd, options); - if (resolvedWorktree) return resolvedWorktree.worktreePath; + if (resolvedWorktree) { + return { + path: resolve(resolvedWorktree.worktreePath), + isPaseoOwnedWorktree: true, + mainRepoRoot: resolvedWorktree.repoRoot, + paseoWorktreesRoot: resolvedWorktree.worktreeRoot, + }; + } const ownership = await isPaseoOwnedWorktreeCwd(cwd, options); - return ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolve(cwd); + return { + path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd), + isPaseoOwnedWorktree: ownership.allowed, + mainRepoRoot: ownership.repoRoot ?? null, + paseoWorktreesRoot: ownership.worktreeRoot ?? null, + }; } async function archiveTargetRecords( @@ -269,15 +297,12 @@ async function archiveTargetRecords( async function maybeRemoveDirectory( dependencies: ArchiveDependencies, - request: Omit, - targetDir: string, + request: Pick, + target: ArchiveTarget, archivedWorkspaceIds: string[], ): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(targetDir, { - paseoHome: dependencies.paseoHome, - worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, - }); - if (!ownership.allowed) { + const backing = target.backing; + if (!backing?.isPaseoOwnedWorktree) { return false; } @@ -285,10 +310,9 @@ async function maybeRemoveDirectory( if ( !(await isDirectoryUnreferenced( remainingActive, - targetDir, + backing.path, new Set(archivedWorkspaceIds), dependencies, - request, )) ) { return false; @@ -296,18 +320,19 @@ async function maybeRemoveDirectory( try { await deletePaseoWorktree({ - cwd: request.repoRoot, - worktreePath: targetDir, - worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot, + cwd: backing.mainRepoRoot, + worktreePath: backing.path, + teardownCwd: target.teardownCwd ?? backing.path, + worktreesRoot: backing.paseoWorktreesRoot ?? undefined, paseoHome: dependencies.paseoHome, - worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); - dependencies.github.invalidate({ cwd: targetDir }); + dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { if (error instanceof WorktreeTeardownError) { dependencies.sessionLogger?.warn( - { err: error, targetPath: targetDir, requestId: request.requestId }, + { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree disk removal failed during archive; workspace already archived", ); return false; @@ -376,7 +401,7 @@ export async function archiveWorkspaceContents( return archivedAgents; } -// EXACTLY one last-reference predicate in the module. True when, after archiving +// True when, after archiving // the in-scope records, no active workspace still points at targetDir. Derived // from records each call — no stored counter. async function isDirectoryUnreferenced( @@ -384,18 +409,13 @@ async function isDirectoryUnreferenced( targetDir: string, archivedWorkspaceIds: ReadonlySet, dependencies: Pick, - request: Pick, ): Promise { const target = resolve(targetDir); const matchesTarget = createRealpathAwarePathMatcher(target); for (const workspace of activeWorkspaces) { if (archivedWorkspaceIds.has(workspace.workspaceId)) continue; - const backingDirectory = await resolveWorkspaceBackingDirectory( - workspace, - dependencies, - request.paseoWorktreesBaseRoot, - ); - if (matchesTarget(backingDirectory)) return false; + const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies); + if (matchesTarget(backingDirectory.path)) return false; } return true; } diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 533b3a3a5..4cdebd624 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -951,6 +951,102 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get(workspace.workspaceId)).toEqual(workspace); }); + test("archives non-directory workspaces without blocking sibling reconciliation", async () => { + const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-file-workspace-")); + const replacedWorkspace = path.join(projectRoot, "replaced-workspace"); + const siblingWorkspace = path.join(projectRoot, "sibling-workspace"); + writeFileSync(replacedWorkspace, "not a directory\n"); + mkdirSync(siblingWorkspace); + tempDirs.push(projectRoot); + + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "replaced", + createPersistedWorkspaceRecord({ + workspaceId: "replaced", + projectId: "p1", + cwd: replacedWorkspace, + kind: "directory", + displayName: "replaced-workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "sibling", + createPersistedWorkspaceRecord({ + workspaceId: "sibling", + projectId: "p1", + cwd: siblingWorkspace, + kind: "directory", + displayName: "sibling-workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: { + getCheckout: async (cwd) => { + if (cwd === replacedWorkspace) { + throw new Error("Git cannot use a regular file as cwd"); + } + return createCheckout(cwd, { + isGit: true, + currentBranch: cwd === siblingWorkspace ? "feature/sibling" : "main", + worktreeRoot: cwd, + }); + }, + }, + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([ + { + kind: "workspace_archived", + workspaceId: "replaced", + directory: replacedWorkspace, + reason: "directory_missing", + }, + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { kind: "git" }, + }, + { + kind: "workspace_updated", + workspaceId: "sibling", + directory: siblingWorkspace, + fields: { + kind: "local_checkout", + branch: "feature/sibling", + worktreeRoot: siblingWorkspace, + }, + }, + ]); + expect(workspaces.get("replaced")?.archivedAt).toEqual(expect.any(String)); + expect(workspaces.get("sibling")).toMatchObject({ + kind: "local_checkout", + branch: "feature/sibling", + }); + }); + test("updates workspace branch metadata without clobbering the workspace name", async () => { const dir = createTempGitRepo("reconcile-branch-"); tempDirs.push(dir); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index d0f17fba8..c95aa880f 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,4 +1,4 @@ -import { existsSync, watch as watchPath } from "node:fs"; +import { statSync, watch as watchPath } from "node:fs"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import type { @@ -107,6 +107,8 @@ interface CachedCheckoutRead { checkout: Promise; } +type DirectoryState = "directory" | "missing" | "unreadable"; + export class WorkspaceReconciliationService { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; @@ -189,13 +191,15 @@ export class WorkspaceReconciliationService { ]); const workspacesByProject = new Map(); for (const workspace of workspaces) { - if (workspace.archivedAt) continue; + if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); } await this.reconcileGitMetadataForProjects( - projects.filter((project) => !project.archivedAt && existsSync(project.rootPath)), + projects.filter( + (project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory", + ), workspacesByProject, changes, ); @@ -212,16 +216,23 @@ export class WorkspaceReconciliationService { const activeProjects = allProjects.filter((p) => !p.archivedAt); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); + const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ + workspace, + state: this.inspectDirectory(workspace.cwd), + })); const workspacesByProject = new Map(); - for (const workspace of activeWorkspaces) { + for (const { workspace, state } of workspaceDirectoryStates) { + if (state !== "directory") continue; const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); workspacesByProject.set(workspace.projectId, list); } // 1. Archive workspaces whose directories no longer exist - const missingWorkspaces = activeWorkspaces.filter((workspace) => !existsSync(workspace.cwd)); + const missingWorkspaces = workspaceDirectoryStates + .filter(({ state }) => state === "missing") + .map(({ workspace }) => workspace); await Promise.all( missingWorkspaces.map(async (workspace) => { const timestamp = new Date().toISOString(); @@ -246,7 +257,7 @@ export class WorkspaceReconciliationService { // Projects persist until explicitly removed, even when they currently have // zero active workspaces, so they still reconcile their own metadata. await this.reconcileGitMetadataForProjects( - activeProjects.filter((project) => existsSync(project.rootPath)), + activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"), workspacesByProject, changes, ); @@ -313,9 +324,8 @@ export class WorkspaceReconciliationService { private async reconcileProject(input: ProjectReconciliationInput): Promise { const { project, siblings, currentGit, readCheckout, changes } = input; - const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd)); const workspaceCheckouts = await Promise.all( - existingSiblings.map(async (workspace) => ({ + siblings.map(async (workspace) => ({ workspace, checkout: await readCheckout(workspace.cwd), })), @@ -476,4 +486,22 @@ export class WorkspaceReconciliationService { } return this.workspaceGitService.getCheckout(cwd); } + + private inspectDirectory(targetPath: string): DirectoryState { + try { + return statSync(targetPath).isDirectory() ? "directory" : "missing"; + } catch (error) { + if (isMissingPathError(error)) return "missing"; + this.logger.warn( + { err: error, targetPath }, + "Skipped workspace reconciliation after directory inspection failed", + ); + return "unreadable"; + } + } +} + +function isMissingPathError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; } diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 7639e7209..1f51a0c4a 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,10 +1,9 @@ import { describe, expect, test } from "vitest"; -import { basename, isAbsolute } from "node:path"; +import { isAbsolute } from "node:path"; import { checkoutFromPersistedWorkspacePlacement, deriveWorkspaceKind, - detectStaleWorkspaces, generateWorkspaceId, generateProjectId, initialWorkspacePlacement, @@ -12,68 +11,6 @@ import { } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; -function createWorkspaceRecord( - cwd: string, - workspaceId: string, - overrides?: { createdAt?: string; archivedAt?: string }, -) { - return createPersistedWorkspaceRecord({ - workspaceId, - projectId: workspaceId, - cwd, - kind: "directory", - displayName: basename(cwd) || cwd, - createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", - updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", - archivedAt: overrides?.archivedAt ?? null, - }); -} - -describe("detectStaleWorkspaces", () => { - test("returns workspace ids whose directories no longer exist", async () => { - const checkedDirectories: string[] = []; - const existingDirectories = new Set(["/tmp/existing"]); - - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/existing", "ws-existing"), - createWorkspaceRecord("/tmp/missing", "ws-missing"), - ], - checkDirectoryExists: async (cwd) => { - checkedDirectories.push(cwd); - return existingDirectories.has(cwd); - }, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]); - expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]); - }); - - test("keeps workspaces whose directories exist even when all agents are archived", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/repo", "ws-repo"), - createWorkspaceRecord("/tmp/other", "ws-other"), - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual([]); - }); - - test("keeps workspaces with no agents when directory exists", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/active", "ws-active"), - createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"), - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual([]); - }); -}); - describe("opaque registry ids", () => { test("generates opaque project ids", () => { expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index f08be536e..acc4d4953 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -9,11 +9,6 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; -export interface DetectStaleWorkspacesInput { - activeWorkspaces: PersistedWorkspaceRecord[]; - checkDirectoryExists: (cwd: string) => Promise; -} - export function generateWorkspaceId(): string { return `wks_${randomBytes(8).toString("hex")}`; } @@ -231,23 +226,3 @@ export function checkoutLiteFromGitSnapshot( mainRepoRoot: git.mainRepoRoot, }; } - -export async function detectStaleWorkspaces( - input: DetectStaleWorkspacesInput, -): Promise> { - const staleWorkspaceIds = new Set(); - - const existenceChecks = await Promise.all( - input.activeWorkspaces.map(async (workspace) => ({ - workspace, - exists: await input.checkDirectoryExists(workspace.cwd), - })), - ); - for (const { workspace, exists } of existenceChecks) { - if (!exists) { - staleWorkspaceIds.add(workspace.workspaceId); - } - } - - return staleWorkspaceIds; -} diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index a0b0195e9..cedc5013c 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -122,9 +122,9 @@ export async function archiveCommand( dependencies: ArchiveCommandDependencies, input: ArchiveCommandInput, ): Promise { - const resolvedTarget = await resolveArchiveTarget(dependencies, input); + const targetPath = await resolveArchiveTarget(dependencies, input); const scope = input.scope ?? "workspace"; - const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { + const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { paseoHome: dependencies.paseoHome, worktreesRoot: dependencies.paseoWorktreesBaseRoot, }); @@ -140,10 +140,7 @@ export async function archiveCommand( } const result = await archiveByScope(dependencies, { - scope: { kind: "worktree", targetPath: resolvedTarget.targetPath }, - repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null, - repoWorktreesRoot: ownership.worktreeRoot, - paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, + scope: { kind: "worktree", targetPath }, requestId: input.requestId, }); @@ -154,11 +151,11 @@ export async function archiveCommand( } const workspaceId = - input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath)); + input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, targetPath)); if (!workspaceId) { dependencies.sessionLogger?.warn( - { targetPath: resolvedTarget.targetPath }, + { targetPath }, "Could not resolve workspace for archive; skipping", ); return { @@ -169,9 +166,6 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "workspace", workspaceId }, - repoRoot: resolvedTarget.repoRoot ?? ownership.repoRoot ?? null, - repoWorktreesRoot: ownership.worktreeRoot, - paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, requestId: input.requestId, }); @@ -181,28 +175,20 @@ export async function archiveCommand( }; } -interface ResolvedArchiveTarget { - targetPath: string; - repoRoot: string | null; -} - async function resolveArchiveTarget( dependencies: ArchiveCommandDependencies, input: ArchiveCommandInput, -): Promise { +): Promise { const repoRoot = input.repoRoot ?? null; if (input.worktreePath) { - return { targetPath: input.worktreePath, repoRoot }; + return input.worktreePath; } if (input.worktreeSlug) { if (!repoRoot) { throw new Error("repoRoot is required when worktreeSlug is supplied"); } - return { - targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug), - repoRoot, - }; + return resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug); } if (repoRoot && input.branchName) { @@ -211,7 +197,7 @@ async function resolveArchiveTarget( if (!match) { throw new Error(`Paseo worktree not found for branch ${input.branchName}`); } - return { targetPath: match.path, repoRoot }; + return match.path; } throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 0b9ed4ec0..243e2eff7 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: { export async function runWorktreeTeardownCommands(options: { worktreePath: string; + teardownCwd?: string; branchName?: string; repoRootPath?: string; }): Promise { - // Read paseo.json from the worktree (it will have the same content as the source repo) - const teardownCommands = getWorktreeTeardownCommands(options.worktreePath); + const teardownCwd = options.teardownCwd ?? options.worktreePath; + if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); + } + const teardownCommands = getWorktreeTeardownCommands(teardownCwd); if (teardownCommands.length === 0) { return []; } @@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: { const results: WorktreeTeardownCommandResult[] = []; for (const cmd of teardownCommands) { const result = await execSetupCommand(cmd, { - cwd: options.worktreePath, + cwd: teardownCwd, env: teardownEnv, }); results.push(result); @@ -1106,6 +1110,7 @@ export async function resolvePaseoWorktreeRootForCwd( export async function deletePaseoWorktree({ cwd, worktreePath, + teardownCwd, worktreeSlug, worktreesRoot, paseoHome, @@ -1113,6 +1118,7 @@ export async function deletePaseoWorktree({ }: { cwd: string | null; worktreePath?: string; + teardownCwd?: string; worktreeSlug?: string; worktreesRoot?: string; paseoHome?: string; @@ -1152,6 +1158,7 @@ export async function deletePaseoWorktree({ if (await pathExists(resolvedWorktree)) { await runWorktreeTeardownCommands({ worktreePath: resolvedWorktree, + teardownCwd, }); }