diff --git a/server/src/__tests__/native-session-resumption.test.ts b/server/src/__tests__/native-session-resumption.test.ts index e85c5c9782f..005da625114 100644 --- a/server/src/__tests__/native-session-resumption.test.ts +++ b/server/src/__tests__/native-session-resumption.test.ts @@ -241,6 +241,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); + const newerExecutionWorkspaceId = randomUUID(); const issueId = randomUUID(); const freshIssueId = randomUUID(); const runId = randomUUID(); @@ -403,8 +404,23 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { cwd: repoRoot, providerType: "local_fs", }); + await db.insert(executionWorkspaces).values({ + id: newerExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + sourceIssueId: issueId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Newer issue workspace", + status: "active", + cwd: repoRoot, + providerType: "local_fs", + }); await db.update(issues).set({ - executionWorkspaceId, + // Simulate a newer run moving the issue-level pointer before the older native run is + // recovered. The older run must still restore its own immutable workspace binding. + executionWorkspaceId: newerExecutionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "shared_workspace" }, }).where(eq(issues.id, issueId)); @@ -523,8 +539,15 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { expect(new Set(effects.map((effect) => effect.decisionId))).toEqual(new Set([decisions[0]!.id])); expect(effects.map((effect) => effect.effectKind).sort()).toEqual(["issue_status_projection", "release_checkout"]); await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([ - expect.objectContaining({ status: "done", statusVersion: 1, lastStatusDecisionId: decisions[0]!.id }), + expect.objectContaining({ + status: "done", + statusVersion: 1, + lastStatusDecisionId: decisions[0]!.id, + executionWorkspaceId: newerExecutionWorkspaceId, + }), ]); + await expect(db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId))) + .resolves.toHaveLength(2); await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([ expect.objectContaining({ phase: "committed", resultId: expect.any(String), assessmentId: expect.any(String), decisionId: decisions[0]!.id }), ]); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f3ceaa3aced..593f07a394e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -14433,12 +14433,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) context.paperclipTaskMarkdownCompact = redactedWakeContext.paperclipTaskMarkdownCompact; } } - const requestedExecutionWorkspaceId = readNonEmptyString(issueRef?.executionWorkspaceId); + // A native run's execution input is immutable once persisted. Recovery must therefore + // restore the workspace bound to that input rather than consulting the issue's current + // workspace pointer: a newer run may already have moved or cleared the issue binding while + // this older provider session is still recoverable. + const persistedRunnerProfile = parseObject(run.runnerProfileJson); + const persistedNativeExecutionInput = + run.runtimeMode === "native" && persistedRunnerProfile.nativeExecutionInput !== undefined + ? parseNativeExecutionInput(persistedRunnerProfile.nativeExecutionInput) + : null; + const nativeRecoveryExecutionWorkspaceId = + persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null; + const requestedExecutionWorkspaceId = nativeRecoveryExecutionWorkspaceId + ?? readNonEmptyString(issueRef?.executionWorkspaceId); const existingExecutionWorkspace = requestedExecutionWorkspaceId ? await executionWorkspacesSvc.getById(requestedExecutionWorkspaceId) : null; const workspaceReuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({ issueExecutionWorkspaceId: requestedExecutionWorkspaceId, - issueExecutionWorkspacePreference: issueRef?.executionWorkspacePreference ?? null, + issueExecutionWorkspacePreference: nativeRecoveryExecutionWorkspaceId + ? "reuse_existing" + : issueRef?.executionWorkspacePreference ?? null, existingExecutionWorkspaceStatus: existingExecutionWorkspace?.status ?? null, }); const requestedShouldReuseExisting = workspaceReuseRequest.requestedShouldReuseExisting; @@ -15187,7 +15201,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) cleanupReason: null, }); } - if (issueId && persistedExecutionWorkspace) { + if (issueId && persistedExecutionWorkspace && !nativeRecoveryExecutionWorkspaceId) { const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); const shouldSwitchIssueToExistingWorkspace = issueRef?.executionWorkspacePreference === "reuse_existing" || @@ -15887,9 +15901,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID(); const nativeSessionId = run.nativeSessionId ?? randomUUID(); - const persistedProfile = parseObject(run.runnerProfileJson); - if (persistedProfile.nativeExecutionInput !== undefined) { - nativeExecution = parseNativeExecutionInput(persistedProfile.nativeExecutionInput); + const persistedProfile = persistedRunnerProfile; + if (persistedNativeExecutionInput) { + nativeExecution = persistedNativeExecutionInput; if ( nativeExecution.binding.companyId !== agent.companyId || nativeExecution.binding.runId !== run.id