diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index c5b97ce978..5d09a51b1a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); const workspaceAutoName = new WorkspaceAutoName({ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 380e3618e0..88090fdf14 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -2,7 +2,7 @@ import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; import { constants, existsSync, unlinkSync } from "fs"; -import { open, rm } from "fs/promises"; +import { open, rm, stat } from "fs/promises"; import { randomUUID } from "node:crypto"; import { hostname as getHostname } from "node:os"; import path from "node:path"; @@ -891,6 +891,7 @@ export async function createPaseoDaemon( projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false, logger, }); const agentProviderRuntime = await createAgentProviderRuntime({ diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 21f9951998..cbcef548e4 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1117,6 +1117,7 @@ function createDeps(options?: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 322bffa8fb..224df7cd3f 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, }); return { workspaceRegistry, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5dbcb63731..c69033ab3f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -902,6 +902,7 @@ export class Session { workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, + isDirectory: (path) => this.filesystem.isDirectory(path), logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0dee0a08d0..b84734eb8b 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -1,9 +1,10 @@ import os from "node:os"; import path from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; +import { getCheckoutStatus } from "../../../utils/checkout-git.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createNoGitWorkspaceRuntimeSnapshot, @@ -16,6 +17,7 @@ import { createPersistedWorkspaceRecord, type WorkspaceRegistry, } from "../../workspace-registry.js"; +import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createWorkspaceProvisioningService, @@ -31,6 +33,15 @@ const logger = createTestLogger(); const ARCHIVED_AT = "2026-01-01T00:00:00.000Z"; const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; +// The real filesystem, so "this directory is gone" is observed rather than modelled. +async function isDirectory(target: string): Promise { + try { + return statSync(target).isDirectory(); + } catch { + return false; + } +} + let tmpDir: string; let gitRoots: Set; let gitBranches: Map; @@ -106,6 +117,7 @@ beforeEach(async () => { workspaceRegistry, projectRegistry, workspaceGitService: gitService(), + isDirectory, logger, }); }); @@ -161,6 +173,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.upsert({ ...first, title: "Pinned work" }); gitRoots.add(repo); @@ -187,6 +200,7 @@ test("persists manual worktree ownership separately from its workspace kind", as const manualWorktreeProvisioning = createWorkspaceProvisioningService({ workspaceRegistry, projectRegistry, + isDirectory, workspaceGitService: createNoopWorkspaceGitService({ peekSnapshot: () => null, getCheckout: async () => ({ @@ -224,6 +238,7 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps test("reopening archived exact-root records restores the fresh Git project", async () => { const cwd = path.join(tmpDir, "repo"); + mkdirSync(cwd, { recursive: true }); const project = await projectRegistry.getOrCreateActiveByRoot({ rootPath: cwd, kind: "non_git", @@ -249,6 +264,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy const archivedProvisioning = createWorkspaceProvisioningService({ workspaceRegistry, projectRegistry, + isDirectory, workspaceGitService: createNoopWorkspaceGitService({ peekSnapshot: () => null, getCheckout: async () => ({ @@ -279,6 +295,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy test("uses one workspace snapshot when reopening an archived workspace", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); @@ -300,6 +317,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( workspaceRegistry: snapshotRegistry, projectRegistry, workspaceGitService: gitService(), + isDirectory, }); const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo); @@ -310,6 +328,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( test("reopening an archived workspace refreshes placement without renaming it", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" }); @@ -430,6 +449,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged test("does not unarchive either record when checkout refresh fails", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await projectRegistry.archive(created.projectId, ARCHIVED_AT); @@ -767,3 +787,136 @@ test.each(["missing", "archived"] as const)( expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []); }, ); + +test("a failed import keeps an archived worktree's placement when its directory is gone", async () => { + // The git port here is the production read: getCheckoutStatus against the real + // filesystem, so "the worktree directory is gone" is observed, not modelled. + const mainRepoRoot = path.join(tmpDir, "main-repo"); + mkdirSync(mainRepoRoot, { recursive: true }); + const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example"); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: mainRepoRoot, + kind: "git", + displayName: "main-repo", + timestamp: ARCHIVED_AT, + }); + const archived = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-worktree", + projectId: project.projectId, + cwd: worktreeCwd, + kind: "worktree", + displayName: "feature/example", + branch: "feature/example", + worktreeRoot: worktreeCwd, + baseBranch: "main", + isPaseoOwnedWorktree: true, + mainRepoRoot, + createdAt: ARCHIVED_AT, + updatedAt: ARCHIVED_AT, + archivedAt: ARCHIVED_AT, + }); + await workspaceRegistry.upsert(archived); + const realCheckoutProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + logger, + isDirectory, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async (cwd: string) => { + const status = await getCheckoutStatus(cwd, { logger }); + return checkoutLiteFromGitSnapshot( + cwd, + status.isGit + ? { + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + repoRoot: status.repoRoot, + isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, + mainRepoRoot: status.mainRepoRoot, + } + : { + isGit: false, + currentBranch: null, + remoteUrl: null, + repoRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + ); + }, + }), + }); + + await expect( + realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => { + throw new Error("provider resume failed"); + }), + ).rejects.toThrow("provider resume failed"); + + expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({ + kind: "worktree", + branch: "feature/example", + worktreeRoot: worktreeCwd, + mainRepoRoot, + isPaseoOwnedWorktree: true, + }); +}); + +test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => { + const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); + gitRoots.add(repo); + gitBranches.set(repo, "feature/away"); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" }); + rmSync(repo, { recursive: true, force: true }); + gitRoots.delete(repo); + + const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + kind: "local_checkout", + branch: "feature/away", + }); +}); + +test("a directory that goes away while the git read is in flight keeps its placement", async () => { + const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); + gitRoots.add(repo); + gitBranches.set(repo, "feature/vanishing"); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" }); + const vanishingProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + isDirectory, + logger, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async (cwd: string) => { + rmSync(repo, { recursive: true, force: true }); + return { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + }, + }), + }); + + const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + kind: "local_checkout", + branch: "feature/vanishing", + }); +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index ecd338ff4e..5149ed5d1c 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -1,6 +1,7 @@ import type { PluginLifecycle } from "../../plugins/lifecycle/index.js"; import { describeHookWorkspace } from "../../plugins/lifecycle/index.js"; import { basename, resolve } from "node:path"; +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type { Logger } from "pino"; import { generateWorkspaceId, @@ -94,11 +95,27 @@ export function createWorkspaceProvisioningService(deps: { workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; workspaceGitService: Pick; + isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + /** + * Placement facts at a workspace directory, or null when there is nothing + * there to read. A git read answers "not a checkout" for a plain directory + * and for a directory that is gone, and only the first is evidence that a + * worktree stopped being one. That answer therefore counts only while the + * directory is there on both sides of the read, so a worktree removed or + * unmounted while the read is in flight stays an absence. + */ + async function observeWorkspaceCheckout(cwd: string): Promise { + if (!(await deps.isDirectory(cwd))) return null; + const checkout = await workspaceGitService.getCheckout(cwd); + if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null; + return checkout; + } + async function runInImportWorkspace( input: ImportWorkspaceInput, operation: (workspace: PersistedWorkspaceRecord) => Promise, @@ -366,12 +383,12 @@ export function createWorkspaceProvisioningService(deps: { const timestamp = new Date().toISOString(); const checkout = workspace.archivedAt || project.archivedAt - ? await workspaceGitService.getCheckout(workspace.cwd) + ? await observeWorkspaceCheckout(workspace.cwd) : null; const autoArchivedChangeRequestUrl = await resolveRestoredAutoArchiveChangeRequestUrl(workspace); let next: PersistedWorkspaceRecord | null = null; - if (workspace.archivedAt && checkout) { + if (workspace.archivedAt) { const placementUpdate = reconcileWorkspacePlacement({ workspace, checkout, @@ -384,10 +401,11 @@ export function createWorkspaceProvisioningService(deps: { updatedAt: timestamp, }; } - if (checkout && (project.archivedAt || workspace.archivedAt)) { - const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd) - ? checkout - : await workspaceGitService.getCheckout(project.rootPath); + if (project.archivedAt || workspace.archivedAt) { + const projectCheckout = + checkout && areEquivalentPaths(project.rootPath, workspace.cwd) + ? checkout + : await workspaceGitService.getCheckout(project.rootPath); const kind = projectCheckout.isGit ? "git" : "non_git"; const projectKey = deriveProjectKey({ rootPath: project.rootPath, @@ -414,10 +432,10 @@ export function createWorkspaceProvisioningService(deps: { async function refreshWorkspaceRecord( workspace: PersistedWorkspaceRecord, ): Promise { - const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const checkout = await observeWorkspaceCheckout(workspace.cwd); const project = await projectRegistry.get(workspace.projectId); if (project && !project.archivedAt) { - await refreshProjectKind(project, workspace.cwd, checkout); + await refreshProjectKind(project, workspace.cwd, checkout ?? undefined); } const update = reconcileWorkspacePlacement({ workspace, diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1f51a0c4a1..a4ad29568c 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -87,6 +87,31 @@ describe("workspace placement", () => { }); }); + test("keeps the recorded placement when nothing was read at the workspace directory", () => { + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "workspace-one", + projectId: "project-one", + cwd: "/repo-feature", + kind: "worktree", + displayName: "feature/placement", + branch: "feature/placement", + worktreeRoot: "/repo-feature", + baseBranch: "main", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + const update = reconcileWorkspacePlacement({ + workspace, + checkout: null, + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + expect(update).toBeNull(); + }); + test("updates live placement while preserving its durable name and base branch", () => { const workspace = createPersistedWorkspaceRecord({ workspaceId: "workspace-one", diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index acc4d49538..181ec96eae 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -109,12 +109,17 @@ export function initialWorkspacePlacement( /** * Applies live placement facts without rewriting the workspace's durable name * or its creation-time base branch. + * + * A null checkout is an absence, not an observation: nothing was read at the + * workspace directory, so the recorded placement stands. Only a directory that + * is there can show that a worktree stopped being one. */ export function reconcileWorkspacePlacement(input: { workspace: PersistedWorkspaceRecord; - checkout: ProjectCheckoutLitePayload; + checkout: ProjectCheckoutLitePayload | null; updatedAt: string; }): WorkspacePlacementUpdate | null { + if (!input.checkout) return null; const observed = initialWorkspacePlacement({ source: "checkout", cwd: input.workspace.cwd, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 0fb32bf558..f28718c0f5 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createLogger(), });