diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 26734c687d..a969382862 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -25,6 +25,7 @@ import { prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, provisionExecutionWorkspaceForFreshnessDecision, + reconcileReusedExecutionWorkspaceProjectWorkspaceId, resolveExecutionWorkspaceConfigFreshness, resolveExecutionWorkspaceReuseRequestForIssue, resolveExecutionWorkspaceReuseProvisioningPolicy, @@ -2884,3 +2885,29 @@ describe("isWorkspaceSyncConflictFailure", () => { expect(isWorkspaceSyncConflictFailure("")).toBe(false); }); }); + +describe("reconcileReusedExecutionWorkspaceProjectWorkspaceId", () => { + it("backfills a null existing binding from the resolved value", () => { + expect( + reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, "resolved-workspace"), + ).toBe("resolved-workspace"); + }); + + it("never overwrites an existing binding, even when a resolved value is present", () => { + expect( + reconcileReusedExecutionWorkspaceProjectWorkspaceId("existing-workspace", "resolved-workspace"), + ).toBe("existing-workspace"); + }); + + it("returns null when both existing and resolved are absent", () => { + expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, null)).toBeNull(); + expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, undefined)).toBeNull(); + expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, undefined)).toBeNull(); + }); + + it("backfills when existing is undefined and resolved is present", () => { + expect( + reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, "resolved-workspace"), + ).toBe("resolved-workspace"); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 11e9e24725..f9eddcbd5b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1979,6 +1979,26 @@ export async function assertPushCapabilityCheckoutValid(input: { ); } +/** + * Reconcile the `projectWorkspaceId` for a reused execution workspace. + * + * A `reuse_existing` workspace can have been persisted with a null + * `projectWorkspaceId` (e.g. it was created before its project had a primary + * project workspace). When we later restore it for a run whose issue now + * expects a concrete project workspace, backfill the column so the launch + * guard (`persisted_workspace_missing_project_workspace_id`) stops rejecting + * it on every requeue — otherwise `reuse_existing` re-binds the same stale + * record forever and the run crash-loops. Prefer the existing binding when + * present so we never null out a good value or silently rebind a genuine + * mismatch (which the guard still surfaces). + */ +export function reconcileReusedExecutionWorkspaceProjectWorkspaceId( + existingProjectWorkspaceId: string | null | undefined, + resolvedProjectWorkspaceId: string | null | undefined, +): string | null { + return existingProjectWorkspaceId ?? resolvedProjectWorkspaceId ?? null; +} + export async function assertGitSensitiveAdapterWorkspaceValid(input: { adapterType: string; agentId: string; @@ -14578,6 +14598,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) status: "active", lastUsedAt: new Date(), metadata: nextExecutionWorkspaceMetadata, + projectWorkspaceId: reconcileReusedExecutionWorkspaceProjectWorkspaceId( + reusableExistingExecutionWorkspace.projectWorkspaceId, + resolvedProjectWorkspaceId, + ), }) : resolvedProjectId ? await executionWorkspacesSvc.create({