Fail projectless git-worktree workspaces during heartbeat setup (#9118)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-06 16:48:05 -07:00 committed by GitHub
parent 329652a2dd
commit a371ceec60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 302 additions and 8 deletions

View File

@ -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 });

View File

@ -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<typeof resolveExecutionWorkspaceMode>,
config: Record<string, unknown>,
): 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<typeof resolveExecutionWorkspaceMode>;
config: Record<string, unknown>;
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<string, unknown> = {}) => {
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
? {