From 336a5b0664a3333cc3bdce985c377e923cb173c2 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 16:19:22 -0700 Subject: [PATCH] Preserve source-free tasks and admitted storage choices Keep task directories outside project provider preparation, and pass the resolved provider or explicit Core choice through source resolution, fresh-attempt checks and preparation. This prevents an installation during setup from changing the admitted storage contract. Cover source-free project tasks, installation during setup, explicit policy preservation and managed preparation with focused heartbeat regressions. Server TypeScript check passes. Co-Authored-By: Paperclip --- packages/plugins/sdk/README.md | 5 ++- .../heartbeat/workspace-preparation.test.ts | 45 +++++++++++++++++-- .../heartbeat/workspace-preparation.ts | 13 ++---- server/src/services/heartbeat/workspaces.ts | 5 ++- server/src/services/plugin-workspaces.ts | 2 + 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 532f71e8e02..579bd8ed26a 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1343,8 +1343,9 @@ inheritance, explicit plugins and Core/local storage. Conflicts remain readable operators can resolve them through this flow. Core reserves an execution-workspace row and binds the task before calling -`onWorkspaceOperation`. Existing workspaces, including local workspaces, retain -their original owner. Changing defaults never migrates, resets or recreates them; +`onWorkspaceOperation`. Source-free task directories do not prepare project storage. The resolved provider +choice, including Core/local, stays fixed through source resolution and preparation. +Existing workspaces, including local workspaces, retain their original owner. Changing defaults never migrates, resets or recreates them; normal retirement and cleanup gates still apply on disable/uninstall. The hook receives a company, project, source revision, execution-workspace ID, diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index efe6b1cc44f..b9c75e11c81 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -101,14 +101,16 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", const [plugin] = await db.insert(plugins).values({ pluginKey: "test.managed-storage", packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"], manifestJson: { id: "test.managed-storage", apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"], workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: "isolated_workspace" as const, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); - if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: plugin.id } }).where(eq(projects.id, issue.projectId!)); - if (inherited) { - const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, true); + const explicitPolicy = { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const, + workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } }; + if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: explicitPolicy }).where(eq(projects.id, issue.projectId!)); + { + const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true); const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null }); input.policy.projectExecutionWorkspacePolicy = policy; input.policy.requestedExecutionWorkspaceMode = mode; input.policy.effectiveExecutionWorkspaceMode = mode; - input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null }); + input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: input.config.mergedConfig, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null }); } await db.update(projectWorkspaces).set({ cwd: null, sourceType: "remote_managed", repoUrl: "https://github.com/example/repo.git" }).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!)); await rm(source, { recursive: true }); @@ -135,6 +137,40 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!)); expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); }); + async function installDefault() { + const [plugin] = await db.insert(plugins).values({ pluginKey: "test.default-storage", packageName: "test-storage", version: "1.0.0", status: "ready", + manifestJson: { id: "test.default-storage", apiVersion: 1, version: "1.0.0", displayName: "Default storage", description: "Storage", author: "Test", categories: ["workspace"], + workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"], defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } }, + capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + return plugin; + } + it("keeps source-free project tasks outside installed provider preparation", async () => { + const { input, issue } = await fixture(); + await installDefault(); + await db.update(issues).set({ projectWorkspaceId: null }).where(eq(issues.id, issue.id)); + await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!)); + input.task.issueRef = { ...issue, projectWorkspaceId: null }; + input.policy.projectExecutionWorkspacePolicy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, false); + const call = vi.fn(); + input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any; + const prepared = await prepareHeartbeatWorkspace(db, input); + expect(prepared.executionWorkspace.cwd).toBeTruthy(); + expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin"); + expect(call).not.toHaveBeenCalled(); + }); + it("pins a resolved Core choice through source selection when a default appears during setup", async () => { + const { input, source } = await fixture(); + // The run resolved Core before installation; its policy and mode are already admitted. + expect(input.policy.projectExecutionWorkspacePolicy).toBeNull(); + await installDefault(); + const call = vi.fn(); + input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any; + const prepared = await prepareHeartbeatWorkspace(db, input); + expect(prepared.executionWorkspace.cwd).toBe(source); + expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin"); + expect(call).not.toHaveBeenCalled(); + }); + const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!); const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!); const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id)); @@ -144,6 +180,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", } }).where(eq(projects.id, issue.projectId!)); + input.policy.projectExecutionWorkspacePolicy = { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }; input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] }; const call = vi.fn(); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index e9459059c67..8b1cca96500 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -1,4 +1,3 @@ -import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js"; import { attachPreservedWorkspaceSnapshots } from "../workspace-recovery-snapshot.js"; import { materializeFreshWorkspaceSeed } from "../fresh-workspace-seed.js"; import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js"; @@ -28,7 +27,7 @@ import path from "node:path"; import { CONTROLLER_PROVISION_COPY_ONLY, workspaceRequiresCopyOnlyProvisioning } from "../workspace-host-provision.js"; import { and, asc, eq } from "drizzle-orm"; import { type ExecutionWorkspace } from "@paperclipai/shared"; -import { heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; +import { heartbeatRuns, issues, projectWorkspaces } from "@paperclipai/db"; import { getStartupTracer } from "../../instrumentation.js"; import { createHostDuplexObservabilityRecorder } from "../duplex-observability-recorder.js"; import { incrementToolRuntimeMetricCounter } from "../tool-runtime-metrics.js"; @@ -331,6 +330,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default", configuredCwd: readNonEmptyString(mergedConfig.cwd), + storageProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null, anchorWorkspace: !pluginTaskWorkspace && requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree" ? await resolveReusedGitWorkspaceAnchor({ agent, @@ -376,15 +376,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace })); const hostProvisionDriver = selectedEnvironmentForConfig?.driver ?? lowTrustPreflightEnvironmentDriver; if (fresh && !isDotRun) { - const [project] = resolvedWorkspace.projectId ? await db.select({ - policy: projects.executionWorkspacePolicy, - }).from(projects).where(and(eq(projects.id, resolvedWorkspace.projectId), - eq(projects.companyId, agent.companyId))) : []; const sourceWorkspace = reusableExistingExecutionWorkspace ?? existingExecutionWorkspace; if (sourceWorkspace?.providerType === "plugin" || readWorkspaceProviderBinding(sourceWorkspace?.metadata) || - project?.policy?.storageProviderPluginId || - (project && !sourceWorkspace && (await effectiveProjectWorkspacePolicy(db, agent.companyId, project.policy, false))?.storageProviderPluginId)) { + (resolvedWorkspace.workspaceId && projectExecutionWorkspacePolicy?.storageProviderPluginId)) { throw new WorkspaceValidationFailure("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", { reason: "fresh_workspace_storage_unsupported", }); @@ -394,7 +389,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId, issueId, runId: run.id, agentId: agent.id, responsibleUserId, existingWorkspaceId: reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, - selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId, + selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null, mode: requestedExecutionWorkspaceMode, strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig), branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create", diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts index 26165e1f9f6..5e4ef54f870 100644 --- a/server/src/services/heartbeat/workspaces.ts +++ b/server/src/services/heartbeat/workspaces.ts @@ -3407,7 +3407,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { agent: typeof agents.$inferSelect, context: Record, previousSessionParams: Record | null, - opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null }, + opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null }, ): Promise { const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); @@ -3509,7 +3509,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { } const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects) .where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : []; - const storageProviderPluginId = sourceProject?.policy?.storageProviderPluginId ?? + const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ?? (sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy, projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null); for (const workspace of projectWorkspaceRows) { @@ -3680,6 +3680,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; + storageProviderPluginId?: string | null; anchorWorkspace?: ResolvedAnchorWorkspaceForRun; }, ): Promise { diff --git a/server/src/services/plugin-workspaces.ts b/server/src/services/plugin-workspaces.ts index f54c05f0346..4d725f9b111 100644 --- a/server/src/services/plugin-workspaces.ts +++ b/server/src/services/plugin-workspaces.ts @@ -185,6 +185,8 @@ export async function preparePluginWorkspace( existing?.metadata?.workspaceProviderRemoval ) throw conflict("Workspace storage is retired or awaiting cleanup"); + // A source-free task directory has no project storage to prepare. Existing bindings still require their owner. + if (!existing && (!input.projectId || !input.projectWorkspaceId)) return null; const saved = readWorkspaceProviderBinding(existing?.metadata); const mode = existing?.mode ?? input.mode, strategy = existing?.strategyType ?? input.strategy;