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:
parent
329652a2dd
commit
a371ceec60
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
Loading…
Reference in New Issue