From 88bf71b84e089d235b2aed625e1f36630466053b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 8 Jul 2026 10:31:37 -0700 Subject: [PATCH] Reject projectless isolated git worktree tasks (#9231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../execution-workspace-policy.test.ts | 119 +++++++++++ ...tbeat-workspace-branch-containment.test.ts | 135 +++++++++++- server/src/__tests__/issues-service.test.ts | 106 ++++++++++ server/src/middleware/error-handler.ts | 1 + .../services/execution-workspace-policy.ts | 69 ++++++- server/src/services/heartbeat.ts | 193 ++++++++++++++++-- server/src/services/issues.ts | 66 ++++++ 7 files changed, 673 insertions(+), 16 deletions(-) diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index f90480a789..8d15dd54dc 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -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({ diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 1e23f826da..cc32d23890 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -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"], diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 39cd067b09..109f857113 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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(); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index afddd0489d..ca1b0c0e78 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -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; diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 5cece4e529..62dec3d707 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -7,7 +7,22 @@ import type { } from "@paperclipai/shared"; import { asString, parseObject } from "../adapters/utils.js"; -type ParsedExecutionWorkspaceMode = Exclude; +export type ParsedExecutionWorkspaceMode = Exclude; + +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 | null | undefined): Record | null { if (!value) return null; @@ -30,6 +45,58 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate }; } +export function resolveEffectiveWorkspaceStrategyType( + mode: ParsedExecutionWorkspaceMode, + config: Record | 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; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 9ac2ba98a4..7f19c59b79 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, - 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; @@ -6242,6 +6235,39 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return runtimeForRun?.sessionId ?? null; } + async function hasResolvableSessionWorkspaceCwd(sessionParams: Record | 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; + taskKey: string | null; + explicitResumeSession: Awaited> | 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 | 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 | 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 }) diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 8cc413df09..620874d712 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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)[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") {