From a371ceec602242d01115928fd65b98a2fe3d7c13 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 16:48:05 -0700 Subject: [PATCH] Fail projectless git-worktree workspaces during heartbeat setup (#9118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent work can run in shared, isolated, or operator-branch execution workspaces > - Isolated/operator git-worktree modes require a real git checkout as their base > - A projectless issue can otherwise resolve to the agent fallback workspace directory > - That fallback is not a valid project checkout for git worktree setup > - This pull request adds a setup-time guard before workspace realization starts > - The benefit is that misconfigured work fails with a typed remediation instead of raw git errors or accidental execution from the agent home directory ## Linked Issues or Issue Description No public GitHub issue was found for this specific failure mode. Inline description follows the bug report template: **What happened?** When a Paperclip issue has no associated project (`projectId: null`) and is configured for `isolated_workspace` or operator-branch execution with `strategy: git_worktree`, the heartbeat setup silently fell back to the `agent_home` directory as the base workspace. Because `agent_home` is not a git repository checkout, the subsequent git worktree operations either failed with raw git errors or — in the degraded path — ran in the wrong directory entirely. **Expected behavior** A projectless issue requesting `git_worktree` execution should fail immediately at setup with a typed `workspace_validation_failed` result and a human-readable remediation message explaining that a project workspace or a reusable execution workspace with a valid git base is required. **Steps to reproduce** 1. Create a Paperclip issue with `projectId: null` (no project attached). 2. Assign it to an agent configured for `isolated_workspace` execution with `strategy: git_worktree`. 3. Trigger a heartbeat run. 4. Observe: the heartbeat resolves the base workspace to `agent_home` and either emits raw git errors during worktree setup or silently executes from an incorrect directory. **Paperclip version or commit** `5cdf5103c` (current `master` HEAD at time of fix) **Deployment mode** Local dev (`pnpm dev`) / built from source — reproduces in any mode because the fallback is in core workspace resolution logic. **Agent adapter(s) involved** Not adapter-specific (core bug — affects all adapters that issue heartbeats for projectless tasks) **Database mode** Not database-related **Access context** Agent (bearer API key via `agent_api_keys`) ## What Changed - Added a heartbeat setup guard that validates isolated/operator `git_worktree` base workspaces before realization. - The guard fails projectless `agent_home` fallback cases with a typed `workspace_validation_failed` result and remediation text. - The guard also fails non-git project base directories before raw git worktree operations run. - Added regression coverage for projectless isolated mode, operator-branch mode, non-git bases, valid git bases, and shared-workspace no-op behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` ## Risks - Low risk. The new guard only applies to issue-backed isolated/operator execution modes using `git_worktree`; shared workspaces and non-git-worktree strategies are left unchanged. - The intentional behavior shift is that invalid git-worktree bases now fail earlier with a structured remediation instead of reaching lower-level git setup. ## Model Used - OpenAI GPT-5 Codex, coding-agent tool-use mode with local command execution; context window size not exposed by this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../heartbeat-workspace-session.test.ts | 206 ++++++++++++++++++ server/src/services/heartbeat.ts | 104 ++++++++- 2 files changed, 302 insertions(+), 8 deletions(-) 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 ? {