diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index d0a4853d09..3b505cb12d 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -10,6 +10,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { applyPersistedExecutionWorkspaceConfig, assertGitSensitiveAdapterWorkspaceValid, + assertGitWorktreeBaseWorkspaceReady, assertPushCapabilityCheckoutValid, buildExplicitResumeSessionOverride, buildEffectiveRunSessionConfigMetadata, @@ -452,6 +453,211 @@ describe("assertGitSensitiveAdapterWorkspaceValid", () => { }); }); +describe("assertGitWorktreeBaseWorkspaceReady", () => { + it("rejects projectless isolated git worktrees that resolved to agent_home", async () => { + const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1"); + + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: null, + projectWorkspaceId: null, + executionWorkspaceId: null, + executionWorkspacePreference: "isolated_workspace", + }, + base: { + baseCwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + message: expect.stringContaining("needs a project / project workspace or a reusable execution workspace"), + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_agent_home", + issueId: "issue-1", + resolvedWorkspaceSource: "agent_home", + requestedExecutionWorkspaceMode: "isolated_workspace", + workspaceStrategyType: "git_worktree", + }), + }, + }); + }); + + it("rejects operator-branch git worktrees that resolved to agent_home", async () => { + const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1"); + + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "operator_branch", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: null, + projectWorkspaceId: null, + }, + base: { + baseCwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_agent_home", + requestedExecutionWorkspaceMode: "operator_branch", + }), + }, + }); + }); + + it("rejects isolated git worktrees when the resolved base is not a git checkout", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-workspace-")); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: "https://github.com/example/repo.git", + repoRef: "origin/master", + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + message: expect.stringContaining("is not a git checkout"), + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_not_git_checkout", + issueId: "issue-1", + resolvedWorkspaceSource: "project_primary", + resolvedWorkspaceCwd: cwd, + }), + }, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("allows isolated git worktrees when the resolved base is a git checkout", async () => { + const cwd = await createGitCheckout({ withRemote: false }); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: "https://github.com/example/repo.git", + repoRef: "origin/master", + }, + })).resolves.toBeUndefined(); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("does not require git for shared project-primary workspaces", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-shared-workspace-")); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "shared_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: null, + }, + })).resolves.toBeUndefined(); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("allows isolated workspace with no explicit strategy type even when base is agent_home", async () => { + // No workspaceStrategy.type → realizeExecutionWorkspace defaults to project_primary (not git_worktree), + // so the guard must not fire. This prevents false workspace_validation_failed for configs that omit type. + const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1"); + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: {}, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: null, + projectWorkspaceId: null, + }, + base: { + baseCwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + })).resolves.toBeUndefined(); + }); + + it("allows operator-branch workspace with no explicit strategy type even when base is agent_home", async () => { + const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1"); + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "operator_branch", + config: {}, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: null, + projectWorkspaceId: null, + }, + base: { + baseCwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + })).resolves.toBeUndefined(); + }); +}); + describe("assertPushCapabilityCheckoutValid", () => { it("rejects a GitHub PR workflow checkout without a configured push remote", async () => { const cwd = await createGitCheckout({ withRemote: false }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6b5282801c..d87149108f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1338,6 +1338,14 @@ async function hasGitMetadata(cwd: string | null | undefined) { .catch(() => false); } +async function isGitCheckout(cwd: string | null | undefined) { + const normalized = readNonEmptyString(cwd); + if (!normalized) return false; + return execFile("git", ["rev-parse", "--show-toplevel"], { cwd: normalized }) + .then((result) => Boolean(readNonEmptyString(result.stdout))) + .catch(() => false); +} + function sameResolvedPath(left: string | null | undefined, right: string | null | undefined) { const leftPath = readNonEmptyString(left); const rightPath = readNonEmptyString(right); @@ -1366,6 +1374,84 @@ async function hasGitPushRemote(cwd: string | null | undefined) { return false; } +function resolveEffectiveWorkspaceStrategyType( + mode: ReturnType, + config: Record, +): string { + const workspaceStrategy = parseObject(config.workspaceStrategy); + // Default mirrors workspace-runtime.ts realizeExecutionWorkspace: missing type → "project_primary". + // agent_default is a metadata-only mode that never creates a worktree, so it keeps "adapter_managed". + return ( + readNonEmptyString(workspaceStrategy.type) ?? + (mode === "agent_default" ? "adapter_managed" : "project_primary") + ); +} + +export async function assertGitWorktreeBaseWorkspaceReady(input: { + requestedExecutionWorkspaceMode: ReturnType; + config: Record; + issue: { + id: string; + identifier: string | null; + projectId: string | null; + projectWorkspaceId: string | null; + executionWorkspaceId?: string | null; + executionWorkspacePreference?: string | null; + } | null; + base: ExecutionWorkspaceInput; +}) { + if (!input.issue) return; + if ( + input.requestedExecutionWorkspaceMode !== "isolated_workspace" && + input.requestedExecutionWorkspaceMode !== "operator_branch" + ) { + return; + } + + const strategyType = resolveEffectiveWorkspaceStrategyType( + input.requestedExecutionWorkspaceMode, + input.config, + ); + if (strategyType !== "git_worktree") return; + + const issueLabel = input.issue.identifier ?? input.issue.id; + const remediation = "This task needs a project / project workspace or a reusable execution workspace before it can run."; + const fail = (reason: string, message: string, extra: Record = {}) => { + throw new WorkspaceValidationFailure(message, { + workspaceValidation: { + reason, + issueId: input.issue!.id, + issueIdentifier: input.issue!.identifier, + issueProjectId: input.issue!.projectId, + issueProjectWorkspaceId: input.issue!.projectWorkspaceId, + issueExecutionWorkspaceId: input.issue!.executionWorkspaceId ?? null, + issueExecutionWorkspacePreference: input.issue!.executionWorkspacePreference ?? null, + requestedExecutionWorkspaceMode: input.requestedExecutionWorkspaceMode, + workspaceStrategyType: strategyType, + resolvedWorkspaceSource: input.base.source, + resolvedProjectId: input.base.projectId, + resolvedProjectWorkspaceId: input.base.workspaceId, + resolvedWorkspaceCwd: input.base.baseCwd, + ...extra, + }, + }); + }; + + if (input.base.source === "agent_home") { + fail( + "git_worktree_base_agent_home", + `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but no project or reusable execution workspace was resolved; refusing to create a git worktree from agent fallback cwd "${input.base.baseCwd}". ${remediation}`, + ); + } + + if (!await isGitCheckout(input.base.baseCwd)) { + fail( + "git_worktree_base_not_git_checkout", + `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but base workspace "${input.base.baseCwd}" is not a git checkout. ${remediation}`, + ); + } +} + export async function assertPushCapabilityCheckoutValid(input: { enabled: boolean; issue: { @@ -10565,17 +10651,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoUrl: resolvedWorkspace.repoUrl, repoRef: resolvedWorkspace.repoRef, } satisfies ExecutionWorkspaceInput; + await assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode, + config: hostExecutionWorkspaceConfig, + issue: issueRef, + base: executionWorkspaceBase, + }); const workspaceStrategyForFingerprint = parseObject(hostExecutionWorkspaceConfig.workspaceStrategy); const workspaceStrategyFingerprintValue = Object.keys(workspaceStrategyForFingerprint).length > 0 ? workspaceStrategyForFingerprint : null; - const latestWorkspaceStrategyType = - readNonEmptyString(workspaceStrategyForFingerprint.type) ?? - (requestedExecutionWorkspaceMode === "agent_default" - ? "adapter_managed" - : requestedExecutionWorkspaceMode === "isolated_workspace" || - requestedExecutionWorkspaceMode === "operator_branch" - ? "git_worktree" - : "project_primary"); + const latestWorkspaceStrategyType = resolveEffectiveWorkspaceStrategyType( + requestedExecutionWorkspaceMode, + hostExecutionWorkspaceConfig, + ); const selectedEnvironmentConfigForFingerprint = parseObject(selectedEnvironmentForConfig?.config); const workspaceEnvironmentFingerprint = selectedEnvironmentForConfig ? {