From 5d42998466ad42400564ba34f48124f40c53fbc5 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 16:52:52 -0700 Subject: [PATCH] Preserve shared provider strategy fallback Ignore agent strategy defaults for non-isolated provider modes when the project and task have no explicit strategy. Move explanations from added code comments into the SDK documentation to follow contribution rules. Verified all 47 workspace policy tests, including primary-source fallback regressions for shared and operator modes. Credential scan and diff checks pass. Co-Authored-By: Paperclip --- packages/plugins/sdk/README.md | 5 +++++ packages/shared/src/types/plugin.ts | 1 - packages/shared/src/types/workspace-runtime.ts | 1 - packages/shared/src/validators/project.ts | 1 - server/src/__tests__/execution-workspace-policy.test.ts | 8 ++++++++ server/src/services/execution-workspace-policy.ts | 3 +-- .../src/services/heartbeat/workspace-preparation.test.ts | 1 - server/src/services/plugin-workspaces.ts | 2 -- server/src/services/project-workspace-provider.ts | 2 -- 9 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 579bd8ed26a..5db1363d7d7 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1336,6 +1336,11 @@ an explicit project choice or disabling a conflicting provider. Worker health do not change the selected default: unavailable providers fail without local fallback. No company settings row means enabled, matching the normal plugin contract. +New and imported tasks leave an unspecified project mode inherited. Provider-owned +workspaces retain explicit project and task strategies in shared and operator-branch +modes. If neither supplies a strategy, those modes use `project_primary`; an agent's +Git strategy remains a fallback only for isolated mode. + Project responses keep the stored `executionWorkspacePolicy` and expose the separate `workspaceProvider` configuration, including its source, effective policy, provider choices and configuration error. The project configuration UI supports diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 030f58bf2f9..3f121a74f45 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -698,7 +698,6 @@ export interface PaperclipPluginManifestV1 { workspaceProvider?: { modes: Array<"shared_workspace" | "isolated_workspace" | "operator_branch">; strategies: Array<"project_primary" | "git_worktree">; - /** Inherit this provider and these Git workspace defaults for projects without an explicit selection. */ defaultForProjects?: { mode: "shared_workspace" | "isolated_workspace" | "operator_branch"; strategy: "project_primary" | "git_worktree"; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index e36c463e26c..00ab237577d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -171,7 +171,6 @@ export interface ExecutionWorkspaceCloseReadiness { export interface ProjectExecutionWorkspacePolicy { enabled: boolean; - /** Omitted inherits the installed default; null explicitly selects Core/local. */ storageProviderPluginId?: string | null; sharedWorkspaceConcurrency?: SharedWorkspaceConcurrency; defaultMode?: ProjectExecutionWorkspaceDefaultMode; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 6ea254cd4d3..61839f7a1b5 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -29,7 +29,6 @@ export const projectExecutionWorkspacePolicySchema = z allowIssueOverride: z.boolean().optional(), defaultProjectWorkspaceId: z.string().guid().optional().nullable(), environmentId: z.string().guid().optional().nullable(), - // Omitted inherits; null explicitly selects Core/local. storageProviderPluginId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index c8093d7a21c..e44ea327f3a 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -52,6 +52,14 @@ describe("execution workspace policy helpers", () => { expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree"); }); + it.each(["shared_workspace", "operator_branch"] as const)("keeps the primary-source fallback for %s providers without a project strategy", mode => { + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: { workspaceStrategy: { type: "git_worktree", baseRef: "agent-branch" } }, + issueSettings: null, mode, legacyUseProjectWorkspace: null, + projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001" } }); + expect(config.workspaceStrategy).toEqual({ type: "project_primary" }); + expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("project_primary"); + }); + it("prefers explicit issue mode over project policy and legacy overrides", () => { expect( resolveExecutionWorkspaceMode({ diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 39ae803c488..f25ee1451f0 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -345,7 +345,6 @@ export function resolveExecutionWorkspaceEnvironmentId(input: { export function defaultIssueExecutionWorkspaceSettingsForProject( projectPolicy: ProjectExecutionWorkspacePolicy | null, ): IssueExecutionWorkspaceSettings | null { - // An unspecified mode must keep inheriting the effective provider default. if (!projectPolicy?.enabled || projectPolicy.defaultMode === undefined) return null; return { mode: @@ -460,7 +459,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: { const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type ? { ...projectStrategy, ...issueStrategy } : issueStrategy ?? projectStrategy ?? - parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ?? + (input.mode === "isolated_workspace" ? parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) : undefined) ?? ({ type: input.mode === "isolated_workspace" ? "git_worktree" : "project_primary" } satisfies ExecutionWorkspaceStrategy); if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) { delete strategy.branchTemplate; diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index a0c613daad3..cc4d2e16dc7 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -164,7 +164,6 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", }); 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(); diff --git a/server/src/services/plugin-workspaces.ts b/server/src/services/plugin-workspaces.ts index 4d725f9b111..e519bd7123a 100644 --- a/server/src/services/plugin-workspaces.ts +++ b/server/src/services/plugin-workspaces.ts @@ -185,7 +185,6 @@ 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, @@ -202,7 +201,6 @@ export async function preparePluginWorkspace( ), ) : []; - // A project default can only select storage for a new workspace. if (existing && existing.providerType !== "plugin" && !saved && !existing.metadata?.workspaceProviderOwner) return null; const owner = saved?.pluginId ?? existing?.metadata?.workspaceProviderOwner ?? (!existing && input.selectedProviderPluginId !== undefined ? input.selectedProviderPluginId : diff --git a/server/src/services/project-workspace-provider.ts b/server/src/services/project-workspace-provider.ts index 26df8244b6f..cc8e8272b2c 100644 --- a/server/src/services/project-workspace-provider.ts +++ b/server/src/services/project-workspace-provider.ts @@ -9,7 +9,6 @@ export function isWorkspaceProvider(manifest: PaperclipPluginManifestV1) { manifest.resourceLifecycle?.includes("project")); } -/** Installation/enablement select the provider; transient worker health must never change it. */ type ProviderDb = Pick; export async function listProjectWorkspaceProviders(db: ProviderDb, companyId: string) { @@ -24,7 +23,6 @@ export async function listProjectWorkspaceProviders(db: ProviderDb, companyId: s type Provider = Awaited>[number]; -/** Keep stored policy separate from effective defaults so reads and edits never turn inheritance into an override. */ export function resolveProjectWorkspaceProvider( rawPolicy: unknown, providers: Provider[],