diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index f90480a7891..b621f753f15 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -155,10 +155,23 @@ describe("execution workspace policy helpers", () => { expect( parseIssueExecutionWorkspaceSettings({ mode: "project_primary", + environmentId: "11111111-1111-4111-8111-111111111111", }), ).toEqual({ mode: "shared_workspace", }); + expect( + parseIssueExecutionWorkspaceSettings( + { + mode: "project_primary", + environmentId: "11111111-1111-4111-8111-111111111111", + }, + { includeEnvironmentId: true }, + ), + ).toEqual({ + mode: "shared_workspace", + environmentId: "11111111-1111-4111-8111-111111111111", + }); }); it("prefers the agent default environment", () => { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 927e4d57469..e3709409e1c 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3084,6 +3084,78 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { ]); }); + it("createChild preserves strategy-only workspace intent without realizing the parent workspace", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const environmentId = 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(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Accepted plan parent", + status: "in_progress", + priority: "medium", + executionWorkspaceSettings: { + mode: "isolated_workspace", + environmentId, + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/master", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + }); + + const { issue: child } = await svc.createChild(parentIssueId, { + title: "Accepted plan child", + status: "todo", + priority: "medium", + executionWorkspaceInheritanceMode: "strategy_only", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectId).toBe(projectId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBeNull(); + expect(child.executionWorkspacePreference).toBeNull(); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + environmentId, + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/master", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }); + }); + it("clamps helper-created child requestDepth to the safe maximum", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index fdbd9914483..b9c45135490 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -88,7 +88,14 @@ export function gateProjectExecutionWorkspacePolicy( return projectPolicy; } -export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null { +type ParseIssueExecutionWorkspaceSettingsOptions = { + includeEnvironmentId?: boolean; +}; + +export function parseIssueExecutionWorkspaceSettings( + raw: unknown, + options: ParseIssueExecutionWorkspaceSettingsOptions = {}, +): IssueExecutionWorkspaceSettings | null { const parsed = parseObject(raw); if (Object.keys(parsed).length === 0) return null; const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy); @@ -112,7 +119,7 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti ...(normalizedMode ? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] } : {}), - ...(parsed.environmentId && typeof parsed.environmentId === "string" + ...(options.includeEnvironmentId && (typeof parsed.environmentId === "string" || parsed.environmentId === null) ? { environmentId: parsed.environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index fc110581619..0c26f316359 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -241,7 +241,7 @@ function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings( // Accepted-plan children are not realized yet, so carry only unresolved // workspace intent and let the first child run render/persist its own branch. function buildPreRealizationExecutionWorkspaceSettings(raw: unknown): Record | null { - const settings = parseIssueExecutionWorkspaceSettings(raw); + const settings = parseIssueExecutionWorkspaceSettings(raw, { includeEnvironmentId: true }); if (!settings) return null; const mode = settings.mode && settings.mode !== "inherit" && settings.mode !== "reuse_existing" @@ -6409,7 +6409,10 @@ export function issueService(db: Db) { let cleared = 0; for (const row of rows) { - const settings = parseIssueExecutionWorkspaceSettings(row.executionWorkspaceSettings); + const settings = parseIssueExecutionWorkspaceSettings( + row.executionWorkspaceSettings, + { includeEnvironmentId: true }, + ); if (settings?.environmentId !== environmentId) continue; await db