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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-22 16:19:22 -07:00
1 parent efc54541f6
commit 336a5b0664
5 files changed
+53 -17

No files matched your search

+3 -2
View File
@@ -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,
@@ -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();
@@ -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",
+3 -2
View File
@@ -3407,7 +3407,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | 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<ResolvedAnchorWorkspaceForRun> {
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<ResolvedWorkspaceForRun> {
+2
View File
@@ -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;