diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 16fc8fd685d..f0d5d898ce6 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -1762,12 +1762,16 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace } const reserve = vi.spyOn(input.services.executionWorkspacesSvc, "assertTaskRepositoryRecoveryBinding"); const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission"); - if (transport !== "daytona" || descriptor === "valid") { + if (descriptor !== "corrupt") { const result = await prepareHeartbeatWorkspace(db, input); expect(result.executionTarget).toEqual(target); if (transport === "ssh" || transport === "task") expect(reserve).not.toHaveBeenCalled(); else expect(reserve).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id, - workspaceId: first.persistedExecutionWorkspace!.id, cwd: admittedRoot, agentId: input.agent.id, runId: input.run.id }); + workspaceId: first.persistedExecutionWorkspace!.id, + // Without a committed sync descriptor the sandbox never received this + // run's work, so recovery re-admits the host workspace it will seed from. + cwd: transport === "daytona" && descriptor === "absent" ? first.executionWorkspace.cwd : admittedRoot, + agentId: input.agent.id, runId: input.run.id }); if (transport !== "daytona") { await expect(prepareNativeWorkspaceSync({ db, runId: input.run.id, companyId: issue.companyId, workspaceId: first.persistedExecutionWorkspace!.id, workspaceLocalDir: first.executionWorkspace.cwd, @@ -1775,8 +1779,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace } expect(admittedRoot).not.toBe(first.executionWorkspace.cwd); } else { - await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow(descriptor === "absent" - ? "native_workspace_sync_descriptor_binding_missing" : "native_workspace_sync_descriptor_digest_mismatch"); + await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("native_workspace_sync_descriptor_digest_mismatch"); expect(reserve).not.toHaveBeenCalled(); expect(await leases(input.run.id)).toMatchObject([{ status: "active" }]); } diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 239a05ebceb..5f3bc76406c 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -1447,10 +1447,13 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace && !(executionTarget?.kind === "remote" && (executionTarget.transport === "ssh" || (executionTarget.transport === "sandbox" && executionTarget.runnerTask)))) { const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!; + // Runtime selection persists the native input before it prepares the + // sandbox sync, and commits this reference before any provider dispatch. + // An absent reference therefore means preparation was interrupted (for + // example by a server restart) before the sandbox received this run's work. + // There is no remote copy-back to protect, so recovery re-admits the host + // workspace that the fresh sync preparation will seed from. const reference = readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync); - if (!reference && executionTarget?.kind === "remote" && executionTarget.transport === "sandbox") { - throw new Error("native_workspace_sync_descriptor_binding_missing"); - } const cwd = reference ? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference }) : nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput)