Reject projectless isolated git worktree tasks (#9231)

## Thinking Path

> - Paperclip is an open-source platform for orchestrating AI agents;
agents run inside execution workspaces that range from a shared
container to full git worktrees cloned from a project repository.
> - Isolated git-worktree workspaces require a project to determine
which repository to clone — without a project the worktree base path
cannot be computed.
> - A task pinned to `isolated_workspace` + `git_worktree` with no
project was previously accepted at creation time but failed late at
dispatch with the opaque `workspace_validation_failed` /
`git_worktree_base_agent_home` error — only after the heartbeat
attempted to provision the workspace.
> - Fail-closed validation should happen in two places: (1) explicit
create/update pins that contradict the requirement are rejected at the
HTTP layer with a structured 422; (2) rows that reach the heartbeat
dispatcher with this invalid combination (e.g. through inheritance or a
retroactively-removed project) are blocked before any heartbeat run or
adapter spawn.
> - This PR adds the shared detection policy, the create/update guard in
the issues service, and the heartbeat pre-dispatch guard, together with
focused unit tests for all three layers.
> - The benefit is deterministic early failure with a clear remediation
message instead of a late, cryptic runtime error.

## Linked Issues or Issue Description

No upstream public GitHub issue — describing the problem inline
(bug-report format).

**What happened?**
Creating an issue with `executionWorkspaceSettings: { mode:
"isolated_workspace", type: "git_worktree" }` and no `projectId` was
accepted without error. The issue then became blocked at dispatch time
with the opaque message `git_worktree_base_agent_home` /
`workspace_validation_failed` — surfaced only after the heartbeat
attempted to provision the workspace.

**Expected behavior**
The platform should reject the invalid combination at create/update time
with a structured 422 that includes a clear remediation message, before
any heartbeat resource is consumed.

**Steps to reproduce**
1. Call `POST /api/issues` (or `PATCH /api/issues/:id`) with
`executionWorkspaceSettings: { mode: "isolated_workspace", type:
"git_worktree" }` and omit `projectId` (or set it to `null`).
2. Observe: request succeeds (200/201).
3. Assign the issue to an agent and watch it enter `blocked` with a
cryptic `workspace_validation_failed` error at dispatch.

**Related prior fix** — Refs #4844 (`fix(validator): reject static cwd
combined with git_worktree strategy`) — same validation area, different
dimension (static cwd vs. missing project).

**Paperclip version / commit**
Latest `master` (pre-this-PR).

**Deployment mode**
Standard (app-global server).

## What Changed

- **`execution-workspace-policy.ts`** — new shared
`detectWorkspaceWorktreeRequiresProject` function returning a stable
`workspace_worktree_requires_project` policy violation when an isolated
git-worktree task has no project, project workspace, or reusable
execution workspace; exports canonical remediation text used by both the
HTTP guard and the heartbeat guard.
- **`issues.ts`** — create and update paths check the new policy before
persisting; explicit pins to `isolated_workspace` / `operator_branch` +
`git_worktree` with no project are rejected with a 422 including the
policy code and remediation text.
- **`heartbeat.ts`** — pre-dispatch preflight checks the same policy for
rows that reach the heartbeat with the invalid combination (e.g. through
inheritance); such rows are marked `blocked` with a skipped wakeup
request, durable issue comment, and activity log before any heartbeat
run or adapter spawn.
- **`execution-workspace-policy.test.ts`** — focused policy-layer unit
tests for detection logic and remediation text.
- **`issues-service.test.ts`** — create/update 422 guard tests for the
new policy.
- **`heartbeat-workspace-branch-containment.test.ts`** — pre-dispatch
blocking test for inherited/ambiguous invalid rows; also fixes a cleanup
race in the existing test suite.

## Verification

```sh
pnpm exec vitest run \
  server/src/__tests__/execution-workspace-policy.test.ts \
  server/src/__tests__/issues-service.test.ts \
  server/src/__tests__/heartbeat-workspace-branch-containment.test.ts

pnpm --filter @paperclipai/server typecheck

# Targeted regression
pnpm exec vitest run \
  server/src/__tests__/heartbeat-workspace-branch-containment.test.ts \
  -t "blocks projectless isolated git-worktree issues before dispatch"
```

All three test files and typecheck passed locally before this PR was
opened.

## Risks

**Low risk.** The policy detection function is pure with no side
effects. The create/update guard only triggers on explicit
`isolated_workspace` or `operator_branch` + `git_worktree` pins combined
with a missing project — it does not fire on inherited settings (handled
by the heartbeat preflight), so there is no false-positive rejection
risk for valid tasks. The heartbeat guard fires before any resource is
provisioned; the only behavioral change for already-invalid rows is that
they receive a clear `blocked` status and durable comment instead of a
late cryptic error.

## Model Used

Claude Sonnet 4.6 (`claude-sonnet-4-6`), 200k context window, tool use
enabled (agentic coding). Used to implement all server-side changes and
tests in this PR.

## 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
- [ ] 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-08 10:31:37 -07:00 committed by GitHub
parent 11177c2665
commit 88bf71b84e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 673 additions and 16 deletions

View File

@ -3,10 +3,12 @@ import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
isUnrunnableWorktreeCombo,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceEnvironmentId,
resolvePinnedIssueWorkspaceStrategyType,
resolveExecutionWorkspaceMode,
} from "../services/execution-workspace-policy.ts";
@ -37,6 +39,123 @@ describe("execution workspace policy helpers", () => {
).toBe("isolated_workspace");
});
it("centralizes unrunnable isolated worktree detection", () => {
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "isolated_workspace",
resolvedStrategy: "git_worktree",
}),
).toBe(true);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: "project-1",
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "isolated_workspace",
resolvedStrategy: "git_worktree",
}),
).toBe(false);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
},
resolvedMode: "isolated_workspace",
resolvedStrategy: "git_worktree",
}),
).toBe(false);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "shared_workspace",
resolvedStrategy: "git_worktree",
}),
).toBe(false);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "agent_default",
resolvedStrategy: "git_worktree",
}),
).toBe(false);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "operator_branch",
resolvedStrategy: "git_worktree",
}),
).toBe(true);
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: "isolated_workspace",
resolvedStrategy: "git_worktree",
hasResolvablePriorSessionWorkspace: true,
}),
).toBe(false);
});
it("mirrors runtime default (project_primary) when pinned settings omit strategy type", () => {
// Mode-only pin without explicit workspaceStrategy.type → same project_primary default as runtime.
expect(
resolvePinnedIssueWorkspaceStrategyType({
mode: "isolated_workspace",
issueSettings: { mode: "isolated_workspace" },
}),
).toBe("project_primary");
// Explicit strategy type is always respected.
expect(
resolvePinnedIssueWorkspaceStrategyType({
mode: "isolated_workspace",
issueSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
}),
).toBe("git_worktree");
expect(
resolvePinnedIssueWorkspaceStrategyType({
mode: "isolated_workspace",
issueSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "project_primary" },
},
}),
).toBe("project_primary");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({

View File

@ -37,6 +37,11 @@ import {
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
} from "../services/execution-workspace-policy.ts";
const execFileAsync = promisify(execFile);
@ -866,8 +871,9 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
await db.delete(environmentLeases);
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
// Heartbeat failure/finalization paths can emit run-linked activity after
// the first cleanup pass observes all runs as non-active.
// Heartbeat failure/finalization paths can emit run-linked events and
// activity after the first cleanup pass observes all runs as non-active.
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(issueComments);
@ -889,6 +895,131 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
await tempDb?.cleanup();
});
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const issueIdentifier = `${issuePrefix}-1`;
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix,
status: "active",
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Projectless isolated worktree",
status: "todo",
workMode: "standard",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: issueIdentifier,
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: { issueId, wakeReason: "issue_assigned" },
});
expect(run).toBeNull();
expect(adapterExecute).not.toHaveBeenCalled();
const runRows = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns);
expect(runRows).toEqual([]);
const blockedIssue = await db
.select({
status: issues.status,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(blockedIssue).toEqual({
status: "blocked",
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
});
const wakeup = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
payload: agentWakeupRequests.payload,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup).toMatchObject({
status: "skipped",
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
});
expect(asRecord(asRecord(wakeup?.payload).heartbeatSkip)).toEqual({
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
});
const comment = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.then((rows) => rows[0] ?? null);
expect(comment?.body).toContain(WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE);
const activity = await db
.select({
action: activityLog.action,
details: activityLog.details,
})
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows[0] ?? null);
expect(activity?.action).toBe("issue.workspace_preflight_blocked");
expect(activity?.details).toMatchObject({
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
resolvedMode: "isolated_workspace",
resolvedStrategy: "git_worktree",
hasResolvablePriorSessionWorkspace: false,
});
});
it.each([
["workspace-runtime fresh worktree reuse", "fresh_realize" as const, null],
["workspace-runtime persisted restore", "persisted_restore" as const, "source-workspace"],

View File

@ -36,6 +36,11 @@ import {
ISSUE_LIST_MAX_LIMIT,
issueService,
} from "../services/issues.ts";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
} from "../services/execution-workspace-policy.ts";
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@ -4091,6 +4096,62 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
});
it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await expect(svc.create(companyId, {
title: "Projectless isolated worktree",
status: "todo",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
})).rejects.toMatchObject({
status: 422,
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
details: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
},
});
});
it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
const issue = await svc.create(companyId, {
title: "Ambiguous inherited worktree",
status: "todo",
priority: "medium",
executionWorkspaceSettings: {
mode: "inherit",
workspaceStrategy: { type: "git_worktree" },
},
});
expect(issue.executionWorkspaceSettings).toEqual({
mode: "inherit",
workspaceStrategy: { type: "git_worktree" },
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@ -4306,6 +4367,51 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
});
it("rejects updates that pin a projectless issue to an isolated git worktree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(agents).values({
id: agentId,
companyId,
name: "Workspace Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const issue = await svc.create(companyId, {
title: "Assign then isolate",
status: "todo",
priority: "medium",
});
await expect(svc.update(issue.id, {
assigneeAgentId: agentId,
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
})).rejects.toMatchObject({
status: 422,
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
details: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
},
});
});
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
const companyId = randomUUID();
const projectId = randomUUID();

View File

@ -92,6 +92,7 @@ export function errorHandler(
res.status(err.status).json({
error: err.message,
...(typeof details?.code === "string" ? { code: details.code } : {}),
...(typeof details?.remediation === "string" ? { remediation: details.remediation } : {}),
...(err.details ? { details: err.details } : {}),
});
return;

View File

@ -7,7 +7,22 @@ import type {
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
export type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE = "workspace_worktree_requires_project";
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION =
"Attach a project to the task, or bind a reusable execution workspace, then retry.";
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE =
`This task is set to run in an isolated git worktree, but it has no project and no reusable execution workspace to create the worktree from. ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`;
type WorkspaceStrategyType = ExecutionWorkspaceStrategy["type"];
export type UnrunnableWorktreeIssueRef = {
projectId?: string | null;
projectWorkspaceId?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
};
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
@ -30,6 +45,58 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate
};
}
export function resolveEffectiveWorkspaceStrategyType(
mode: ParsedExecutionWorkspaceMode,
config: Record<string, unknown> | null | undefined,
): WorkspaceStrategyType {
const workspaceStrategy = parseObject(config?.workspaceStrategy);
const type = asString(workspaceStrategy.type, "");
if (type === "project_primary" || type === "git_worktree" || type === "adapter_managed" || type === "cloud_sandbox") {
return type;
}
// 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 mode === "agent_default" ? "adapter_managed" : "project_primary";
}
export function resolvePinnedIssueWorkspaceStrategyType(input: {
mode: ParsedExecutionWorkspaceMode;
issueSettings: IssueExecutionWorkspaceSettings | null;
}): WorkspaceStrategyType {
const strategyType = input.issueSettings?.workspaceStrategy?.type;
if (
strategyType === "project_primary" ||
strategyType === "git_worktree" ||
strategyType === "adapter_managed" ||
strategyType === "cloud_sandbox"
) {
return strategyType;
}
// When no explicit strategy type is set, mirror the runtime default (project_primary for most
// modes; adapter_managed for agent_default). Mode alone never implies git_worktree.
return input.mode === "agent_default" ? "adapter_managed" : "project_primary";
}
export function hasReusableExecutionWorkspaceBinding(issue: UnrunnableWorktreeIssueRef): boolean {
return Boolean(issue.executionWorkspaceId && issue.executionWorkspacePreference === "reuse_existing");
}
export function isUnrunnableWorktreeCombo(input: {
issue: UnrunnableWorktreeIssueRef;
resolvedMode: ParsedExecutionWorkspaceMode;
resolvedStrategy: string | null | undefined;
reusableExecutionWorkspaceAvailable?: boolean | null;
hasResolvablePriorSessionWorkspace?: boolean | null;
}): boolean {
if (input.resolvedMode !== "isolated_workspace" && input.resolvedMode !== "operator_branch") return false;
if (input.resolvedStrategy !== "git_worktree") return false;
if (input.issue.projectId || input.issue.projectWorkspaceId) return false;
const hasReusableWorkspace =
input.reusableExecutionWorkspaceAvailable ?? hasReusableExecutionWorkspaceBinding(input.issue);
if (hasReusableWorkspace) return false;
return input.hasResolvablePriorSessionWorkspace !== true;
}
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;

View File

@ -40,6 +40,7 @@ import {
documentAnnotationThreads,
documentRevisions,
issueDocuments,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueApprovals,
@ -138,10 +139,15 @@ import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
isUnrunnableWorktreeCombo,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveEffectiveWorkspaceStrategyType,
resolveExecutionWorkspaceEnvironmentId,
resolveExecutionWorkspaceMode,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import {
@ -1377,19 +1383,6 @@ 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>;
@ -6242,6 +6235,39 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return runtimeForRun?.sessionId ?? null;
}
async function hasResolvableSessionWorkspaceCwd(sessionParams: Record<string, unknown> | null | undefined) {
const cwd = readNonEmptyString(sessionParams?.cwd);
if (!cwd || isUnsafeSessionWorkspaceCwd(cwd)) return false;
return fs
.stat(cwd)
.then((stats) => stats.isDirectory())
.catch(() => false);
}
async function hasResolvablePriorSessionWorkspaceForWake(input: {
agent: typeof agents.$inferSelect;
contextSnapshot: Record<string, unknown>;
taskKey: string | null;
explicitResumeSession: Awaited<ReturnType<typeof resolveExplicitResumeSessionOverride>> | null;
}) {
if (await hasResolvableSessionWorkspaceCwd(input.explicitResumeSession?.sessionParams)) return true;
if (shouldResetTaskSessionForWake(input.contextSnapshot)) return false;
if (!input.taskKey) return false;
const codec = getAdapterSessionCodec(input.agent.adapterType);
const taskSession = await getTaskSession(
input.agent.companyId,
input.agent.id,
input.agent.adapterType,
input.taskKey,
);
const taskSessionParams = normalizeResumeParamsForAdapter(
input.agent.adapterType,
codec.deserialize(taskSession?.sessionParamsJson ?? null),
);
return hasResolvableSessionWorkspaceCwd(taskSessionParams);
}
async function resolveExplicitResumeSessionOverride(
agent: typeof agents.$inferSelect,
payload: Record<string, unknown> | null,
@ -13339,6 +13365,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const sessionBefore =
explicitResumeSession?.sessionDisplayId ??
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
let hasResolvablePriorSessionWorkspace: boolean | null = null;
const resolveHasResolvablePriorSessionWorkspace = async () => {
if (hasResolvablePriorSessionWorkspace !== null) return hasResolvablePriorSessionWorkspace;
hasResolvablePriorSessionWorkspace = issueId
? await hasResolvablePriorSessionWorkspaceForWake({
agent,
contextSnapshot: enrichedContextSnapshot,
taskKey: effectiveTaskKey,
explicitResumeSession,
})
: false;
return hasResolvablePriorSessionWorkspace;
};
const continuationAttempt = readContinuationAttempt(enrichedContextSnapshot.livenessContinuationAttempt);
let projectId = readNonEmptyString(enrichedContextSnapshot.projectId);
@ -13375,6 +13414,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
enrichedContextSnapshot.projectId = projectId;
}
const isolatedWorkspacesEnabled = issueId
? (await instanceSettings.getExperimental()).enableIsolatedWorkspaces
: false;
let queuedResponsibleUserIdPromise: Promise<string> | null = null;
const resolveQueuedResponsibleUserId = () => {
queuedResponsibleUserIdPromise ??= (async () => {
@ -13520,7 +13562,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
status: issues.status,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
assigneeAgentId: issues.assigneeAgentId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
@ -13802,6 +13850,125 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "skipped" as const };
}
if (isolatedWorkspacesEnabled && !activeExecutionRun && issue.status !== "done" && issue.status !== "cancelled") {
const issueSettings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
const resolvedMode = resolveExecutionWorkspaceMode({
projectPolicy: null,
issueSettings,
legacyUseProjectWorkspace: null,
});
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: parseObject(agent.adapterConfig),
projectPolicy: null,
issueSettings,
mode: resolvedMode,
legacyUseProjectWorkspace: null,
});
const resolvedStrategy = resolveEffectiveWorkspaceStrategyType(resolvedMode, workspaceManagedConfig);
const existingExecutionWorkspaceStatus = issue.executionWorkspaceId
? await tx
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(and(
eq(executionWorkspaces.id, issue.executionWorkspaceId),
eq(executionWorkspaces.companyId, issue.companyId),
))
.then((rows) => rows[0]?.status ?? null)
: null;
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: issue.executionWorkspaceId,
issueExecutionWorkspacePreference: issue.executionWorkspacePreference,
existingExecutionWorkspaceStatus,
});
const hasResolvablePriorSessionWorkspace = await resolveHasResolvablePriorSessionWorkspace();
if (
isUnrunnableWorktreeCombo({
issue: {
projectId: issue.projectId ?? projectId ?? null,
projectWorkspaceId: issue.projectWorkspaceId,
executionWorkspaceId: issue.executionWorkspaceId,
executionWorkspacePreference: issue.executionWorkspacePreference,
},
resolvedMode,
resolvedStrategy,
reusableExecutionWorkspaceAvailable: reuseRequest.existingExecutionWorkspaceAvailable,
hasResolvablePriorSessionWorkspace,
})
) {
const now = new Date();
const issueLabel = formatIssueIdentifierLink(issue.identifier, issue.id);
const blockedComment = [
`Paperclip blocked ${issueLabel} before dispatch because its workspace settings are not runnable.`,
"",
`- Code: \`${WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE}\``,
`- Reason: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE}`,
`- Next action: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`,
].join("\n");
await tx
.update(issues)
.set({
status: "blocked",
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: now,
})
.where(eq(issues.id, issue.id));
await tx.insert(issueComments).values({
companyId: issue.companyId,
issueId: issue.id,
body: blockedComment,
createdAt: now,
updatedAt: now,
});
await tx.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
source,
triggerDetail,
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
payload: {
...(payload ?? {}),
issueId,
heartbeatSkip: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
},
},
status: "skipped",
requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null,
finishedAt: now,
});
await logActivity(tx as unknown as Db, {
companyId: issue.companyId,
actorType: "system",
actorId: "system",
agentId,
runId: null,
action: "issue.workspace_preflight_blocked",
entityType: "issue",
entityId: issue.id,
details: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
requestedReason: reason,
source,
triggerDetail,
resolvedMode,
resolvedStrategy,
hasResolvablePriorSessionWorkspace,
},
});
return { kind: "skipped" as const };
}
}
if (activeExecutionRun) {
const executionAgent = await tx
.select({ name: agents.name })

View File

@ -69,8 +69,14 @@ import {
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
isUnrunnableWorktreeCombo,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolvePinnedIssueWorkspaceStrategyType,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
type ParsedExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js";
@ -167,6 +173,48 @@ function applyStatusSideEffects(
return patch;
}
function workspaceWorktreeRequiresProjectDetails() {
return {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
};
}
function assertExplicitPinnedWorktreeIssueRunnable(input: {
projectId: string | null | undefined;
projectWorkspaceId: string | null | undefined;
executionWorkspaceId: string | null | undefined;
executionWorkspacePreference: string | null | undefined;
executionWorkspaceSettings: unknown;
}) {
const settings = parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings);
const mode = settings?.mode;
if (mode !== "isolated_workspace" && mode !== "operator_branch") return;
const resolvedMode = mode as ParsedExecutionWorkspaceMode;
if (
isUnrunnableWorktreeCombo({
issue: {
projectId: input.projectId ?? null,
projectWorkspaceId: input.projectWorkspaceId ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
},
resolvedMode,
resolvedStrategy: resolvePinnedIssueWorkspaceStrategyType({
mode: resolvedMode,
issueSettings: settings,
}),
hasResolvablePriorSessionWorkspace: false,
})
) {
throw unprocessable(
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
workspaceWorktreeRequiresProjectDetails(),
);
}
}
function readStringFromRecord(record: unknown, key: string) {
if (!record || typeof record !== "object") return null;
const value = (record as Record<string, unknown>)[key];
@ -6008,6 +6056,15 @@ export function issueService(db: Db) {
if (executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx);
}
if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) {
assertExplicitPinnedWorktreeIssueRunnable({
projectId: issueData.projectId ?? null,
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference,
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
});
}
// Self-correcting counter: use MAX(issue_number) + 1 if the counter
// has drifted below the actual max, preventing identifier collisions.
const [maxRow] = await tx
@ -6223,6 +6280,15 @@ export function issueService(db: Db) {
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
}
}
if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) {
assertExplicitPinnedWorktreeIssueRunnable({
projectId: nextProjectId ?? null,
projectWorkspaceId: nextProjectWorkspaceId ?? null,
executionWorkspaceId: nextExecutionWorkspaceId ?? null,
executionWorkspacePreference: nextExecutionWorkspacePreference ?? null,
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
});
}
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {