From 5a1e17f27fb681d8fd5ad127b80f323ffeeffa5e Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 07:44:11 -0500 Subject: [PATCH] Fix issue workspace reuse after isolation Persist realized isolated/operator workspaces back onto the issue as reusable workspaces so later runs stay on the same workspace, and update the issue workspace picker to present realized isolated workspaces as existing workspaces. Co-Authored-By: Paperclip --- .../execution-workspace-policy.test.ts | 9 +++++ .../services/execution-workspace-policy.ts | 12 +++++++ server/src/services/heartbeat.ts | 29 +++++++++++++--- ui/src/components/IssueProperties.tsx | 33 ++++++++++++++----- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index a52fba4e81..71ef619218 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -3,6 +3,7 @@ import { buildExecutionWorkspaceAdapterConfig, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -142,6 +143,14 @@ describe("execution workspace policy helpers", () => { }); }); + it("maps persisted execution workspace modes back to issue settings", () => { + expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default"); + }); + it("disables project execution workspace policy when the instance flag is off", () => { expect( gateProjectExecutionWorkspacePolicy( diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 53487324e5..4f79beb399 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -132,6 +132,18 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( }; } +export function issueExecutionWorkspaceModeForPersistedWorkspace( + mode: string | null | undefined, +): IssueExecutionWorkspaceSettings["mode"] { + if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") { + return mode; + } + if (mode === "adapter_managed" || mode === "cloud_sandbox") { + return "agent_default"; + } + return "shared_workspace"; +} + export function resolveExecutionWorkspaceMode(input: { projectPolicy: ProjectExecutionWorkspacePolicy | null; issueSettings: IssueExecutionWorkspaceSettings | null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0694efed98..96a42f7e7e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -2098,11 +2099,29 @@ export function heartbeatService(db: Db) { cleanupReason: null, }); } - if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { - await issuesSvc.update(issueId, { - executionWorkspaceId: persistedExecutionWorkspace.id, - ...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}), - }); + if (issueId && persistedExecutionWorkspace) { + const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); + const shouldSwitchIssueToExistingWorkspace = + issueRef?.executionWorkspacePreference === "reuse_existing" || + executionWorkspaceMode === "isolated_workspace" || + executionWorkspaceMode === "operator_branch"; + const nextIssuePatch: Record = {}; + if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { + nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; + } + if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { + nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; + } + if (shouldSwitchIssueToExistingWorkspace) { + nextIssuePatch.executionWorkspacePreference = "reuse_existing"; + nextIssuePatch.executionWorkspaceSettings = { + ...(issueExecutionWorkspaceSettings ?? {}), + mode: nextIssueWorkspaceMode, + }; + } + if (Object.keys(nextIssuePatch).length > 0) { + await issuesSvc.update(issueId, nextIssuePatch); + } } if (persistedExecutionWorkspace) { context.executionWorkspaceId = persistedExecutionWorkspace.id; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 053e8e424b..935a3b95f2 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -53,6 +53,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) { return "shared_workspace"; } +function shouldPresentExistingWorkspaceSelection(issue: Issue) { + const persistedMode = + issue.currentExecutionWorkspace?.mode + ?? issue.executionWorkspaceSettings?.mode + ?? issue.executionWorkspacePreference; + return Boolean( + issue.executionWorkspaceId && + (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), + ); +} + interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -268,10 +279,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp ? currentProject?.executionWorkspacePolicy ?? null : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const currentExecutionWorkspaceSelection = - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject); const { data: reusableExecutionWorkspaces } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, @@ -298,9 +305,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp } return Array.from(seen.values()); }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( - (workspace) => workspace.id === issue.executionWorkspaceId, - ); + const selectedReusableExecutionWorkspace = + deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) + ?? issue.currentExecutionWorkspace + ?? null; + const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) + ? "reuse_existing" + : ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(currentProject) + ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -680,7 +695,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > {EXECUTION_WORKSPACE_OPTIONS.map((option) => ( ))}