diff --git a/server/src/__tests__/execution-workspace-reopen.test.ts b/server/src/__tests__/execution-workspace-reopen.test.ts index ea12f68e85..9ff003a237 100644 --- a/server/src/__tests__/execution-workspace-reopen.test.ts +++ b/server/src/__tests__/execution-workspace-reopen.test.ts @@ -1,4 +1,6 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -27,6 +29,7 @@ import { readExecutionWorkspaceLifecycleGeneration, readMetadataReopenPendingConsumptionSince, } from "../services/execution-workspaces.js"; +import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -128,6 +131,96 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { return workspaceId; } + // Seed a company and project whose primary project workspace has a null cwd. + // This models a managed_checkout project: the base is not a local folder, so + // the live managed checkout supplies the base path at rebuild time. + async function seedManagedCheckoutProject() { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `PAP-${companyId.slice(0, 8)}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Managed checkout project", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "managed_checkout", + cwd: null, + isPrimary: true, + }); + return { companyId, projectId, projectWorkspaceId }; + } + + // Seed one closed isolated git_worktree row. The reaper already removed the + // worktree directory, so cwd points at a path that is not on disk. + async function seedClosedGitWorktreeWorkspace(input: { + companyId: string; + projectId: string; + projectWorkspaceId: string; + cwd: string; + repoUrl: string; + branchName: string; + }) { + const workspaceId = randomUUID(); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId: input.companyId, + projectId: input.projectId, + projectWorkspaceId: input.projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "reopen-git-worktree", + status: "archived", + providerType: "local_fs", + cwd: input.cwd, + repoUrl: input.repoUrl, + baseRef: null, + branchName: input.branchName, + closedAt: new Date(), + cleanupReason: "issue_terminal", + cleanupEligibleAt: new Date(), + metadata: { + [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, + }, + }); + return workspaceId; + } + + // Build a real git repository at the managed checkout path so + // ensureManagedProjectWorkspace adopts it without a network clone. The repo + // holds the branch that the archived worktree row references. + function initManagedGitRepo(dir: string, worktreeBranch: string) { + mkdirSync(dir, { recursive: true }); + const git = (...args: string[]) => + execFileSync("git", args, { + cwd: dir, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + stdio: "ignore", + }); + git("init"); + writeFileSync(join(dir, "README.md"), "seed\n"); + git("add", "README.md"); + git("commit", "-m", "seed"); + git("branch", worktreeBranch); + } + async function seedIssue(input: { companyId: string; projectId: string; @@ -246,6 +339,56 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record | null)).toBe(3); }); + it("resolves the managed base checkout for a git_worktree row when the project workspace cwd is null", async () => { + const previousHome = process.env.PAPERCLIP_HOME; + const tempHome = await mkdtemp(join(tmpdir(), "paperclip-reopen-home-")); + tempDirs.push(tempHome); + process.env.PAPERCLIP_HOME = tempHome; + try { + const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject(); + const repoUrl = "https://example.test/acme/widget.git"; + const branchName = "reopen-feature"; + // Build the live managed checkout that the rebuild must spawn git in. + const managedDir = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "widget" }); + initManagedGitRepo(managedDir, branchName); + + // The archived worktree path. The reaper already removed it from disk. + const deletedWorktree = join(tempHome, "worktrees", "reopen-worktree"); + const workspaceId = await seedClosedGitWorktreeWorkspace({ + companyId, + projectId, + projectWorkspaceId, + cwd: deletedWorktree, + repoUrl, + branchName, + }); + const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4305 }); + + const svc = executionWorkspaceService(db); + const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({ + workspaceId, + issue: { id: issueId, companyId, projectId }, + actor: { agentId: null, actorType: "user" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.reopened).toBe(true); + + const row = await readWorkspace(workspaceId); + expect(row?.status).toBe("active"); + expect(row?.closedAt).toBeNull(); + expect(row?.cleanupReason).toBeNull(); + // The rebuild recreated the worktree at the archived path from the managed + // base checkout. + const worktreeStat = await stat(deletedWorktree).catch(() => null); + expect(worktreeStat?.isDirectory()).toBe(true); + } finally { + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousHome; + } + }); + it("refuses to reopen a workspace in another company", async () => { const first = await seedCompanyProject(); const second = await seedCompanyProject(); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index cf052cb437..b5e97ee126 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -3127,6 +3127,100 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n"); }, 15_000); + it("rejects an empty base checkout path with a clear cause", async () => { + // An empty base path makes the later "git" spawn fail with a raw ENOENT. + // The reopen must throw a clear cause first that names the empty checkout. + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: "", + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: "/does-not-exist/worktree", + providerRef: "/does-not-exist/worktree", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: "feature-branch", + }, + issue: { + id: "issue-1", + identifier: "PAP-461", + title: "Empty base checkout path", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Cannot rebuild the git worktree: the base project checkout path is empty.", + ); + }); + + it("preserves a whitespace-only base checkout path and reports it as missing", async () => { + // The reopen must use the persisted base path exactly. A trim would change + // a whitespace-only path into an empty path and hide the real cause. + // A directory name can consist of spaces, so keep the path unchanged and + // let the directory-exists check report the missing checkout. + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: " ", + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: "/does-not-exist/worktree", + providerRef: "/does-not-exist/worktree", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: "feature-branch", + }, + issue: { + id: "issue-1", + identifier: "PAP-461", + title: "Whitespace base checkout path", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Cannot rebuild the git worktree: the base project checkout directory does not exist.", + ); + }); + it("auto-detects the default branch when baseRef is not configured", async () => { // Create a repo with "master" as default branch (not "main") const repoRoot = await createTempRepo("master"); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 4e32a69a37..2a3cddc4d0 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -52,6 +52,7 @@ import { type PullRequestMergeDetailsResolver, } from "./github-pull-request-merge.js"; import { visibleIssueCondition } from "./issue-visibility.js"; +import { createGitRemoteAuthProvider } from "./git-credentials.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { listCurrentRuntimeServicesForExecutionWorkspaces, @@ -2764,11 +2765,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }; } - const [{ ensurePersistedExecutionWorkspaceAvailable }, { workspaceOperationService }] = - await Promise.all([ - import("./workspace-runtime.js"), - import("./workspace-operations.js"), - ]); + const [ + { ensurePersistedExecutionWorkspaceAvailable }, + { workspaceOperationService }, + { ensureManagedProjectWorkspace }, + ] = await Promise.all([ + import("./workspace-runtime.js"), + import("./workspace-operations.js"), + // heartbeat.js imports this module, so a static import creates a + // cycle. Load ensureManagedProjectWorkspace dynamically instead. + import("./heartbeat.js"), + ]); const [projectWorkspace, projectPolicy] = await Promise.all([ row.projectWorkspaceId ? db @@ -2786,6 +2793,25 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(and(eq(projects.companyId, row.companyId), eq(projects.id, row.projectId))) .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)), ]); + // Resolve the base checkout that the rebuild spawns git in. A + // local-folder project stores its base path in projectWorkspaces.cwd. + // A managed_checkout project stores null there, so resolve its live + // managed checkout instead. Never use row.cwd for a git_worktree + // rebuild: row.cwd is the archived worktree path, which the reaper + // already removed from disk. A spawn in that missing directory fails + // with "spawn git ENOENT" and hides the real cause. + let resolvedBaseCwd = projectWorkspace?.cwd ?? null; + if (resolvedBaseCwd == null && row.strategyType === "git_worktree" && row.projectId) { + const managedWorkspace = await ensureManagedProjectWorkspace({ + companyId: row.companyId, + projectId: row.projectId, + repoUrl: row.repoUrl, + resolveGitAuth: createGitRemoteAuthProvider(db, row.companyId, { + issueId: row.sourceIssueId ?? issue.id, + }), + }); + resolvedBaseCwd = managedWorkspace.cwd; + } const config = readExecutionWorkspaceConfig(row.metadata as Record | null); const nextGeneration = readExecutionWorkspaceLifecycleGeneration( row.metadata as Record | null, @@ -2803,7 +2829,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const realized = await ensurePersistedExecutionWorkspaceAvailable({ db: tx as unknown as Db, base: { - baseCwd: projectWorkspace?.cwd ?? row.cwd ?? "", + baseCwd: resolvedBaseCwd ?? row.cwd ?? "", source: "task_session", projectId: row.projectId, workspaceId: row.projectWorkspaceId, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index cb96c5f9d4..563b2f6db6 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -3031,7 +3031,24 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { } return realized; } - const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + // Validate the base checkout before the git spawn. A missing or empty base + // path makes the "git" spawn fail with a raw "spawn git ENOENT" error. That + // error hides the real cause: the base project checkout is not on disk. + // Throw a clear cause first so a future failure names the missing checkout. + // Keep the persisted path exact. A directory name can start or end with a + // space, so a trim would change a valid checkout path. + const baseCwd = asString(input.base.baseCwd, ""); + if (!baseCwd) { + throw new Error( + "Cannot rebuild the git worktree: the base project checkout path is empty.", + ); + } + if (!await directoryExists(baseCwd)) { + throw new Error( + "Cannot rebuild the git worktree: the base project checkout directory does not exist.", + ); + } + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], baseCwd); const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata); if (await directoryExists(cwd)) { const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;