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 a52fba4e..71ef6192 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 53487324..4f79beb3 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 0694efed..96a42f7e 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 053e8e42..935a3b95 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) => ( ))}