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") {