Cursor博客 - 影子工作区: 在后台迭代代码

October 11, 2024

这是一个失败的案例:将一些相关的文件粘贴到Google文档中,发送链接给你最喜欢的p60软件工程师,他对你的代码库一无所知,然后让他在文档中完整且正确地实现你的下一个PR。

让AI做同样的事,结果也会不出意料地失败。

现在,与其让他们直接修改你的代码,不如赋予他们远程访问你的开发环境的权限,让他们能够查看代码检查结果、跳转到定义以及运行代码,这样你或许可以期待他们提供一些实际的帮助。

代码调试 图1:你是愿意在专业的代码工具里调试复杂的代码,还是在简单的文档软件里调试?AI 也会选择前者。

我们认为,让AI编写更多代码的一个关键因素是它能够在你的开发环境中进行迭代。然而,天真地让AI自由地在你的文件夹中操作会导致混乱:想象一下,你正在编写一个需要大量推理的函数,而AI覆盖了它,或者你尝试运行程序,而AI插入了无法编译的代码。为了真正有帮助,AI的迭代必须在后台进行,不影响你的编码体验。

为了实现这一点,我们在Cursor中实现了一个我们称之为“影子工作区”的功能。在这篇博客中,我将首先概述我们的设计标准,然后描述目前在Cursor中实现的方案(一个隐藏的Electron窗口)以及我们未来打算如何改进它(内核级文件夹代理)。

打开影子工作区 图2:Cursor 中的影子工作区开关。目前是可选的。

设计标准

我们希望影子工作区实现以下目标:

  1. LSP-可用性:AI 应该能够看到代码更改产生的代码检查信息(lints),能够跳转到定义,并且能够与语言服务器协议(LSP)的所有部分进行交互。
  2. 可运行性:AI应能够运行代码并查看输出。

我们最初专注于LSP-可用性。

这些目标应符合以下要求:

  1. 独立性:用户的编码体验必须不受影响。
  2. 隐私:用户的代码应保持安全(例如通过将其保存在本地)。
  3. 并发性:多个AI应该能够同时工作。
  4. 通用性:它应该适用于所有编程语言和工作区设置。
  5. 可维护性:应该尽可能编写少量的,且具备隔离性的代码。
  6. 速度:不能有分钟级的延迟,且应有足够的吞吐量以支持数百个AI分支。

这些反映了为超过十万用户构建代码编辑器的实际情况。我们不希望对任何人的编码体验产生负面影响。

实现LSP(语言服务器协议)的可用性

让 AI 检查自己修改代码时的错误(就像代码检查工具一样),是在不改变 AI 模型本身的情况下,提升代码生成性能最有效的方法之一。这种方法不仅能让代码的正确率从 90% 提升到 100%,还能在 AI 受到限制的情况下提供很大帮助。比如,当 AI 需要猜测该使用什么方法或服务时,代码检查工具(lint)是 AI 应该寻求更多信息的地方。

多次迭代 图3 AI 根据代码检查多次迭代代码,实现了一个函数。

此外,比起检查代码能否运行,检查代码是否符合语言服务器协议(LSP)的标准要简单得多,因为几乎所有语言服务器都能处理那些尚未保存到文件系统中的代码文件(我们稍后会看到,涉及文件系统会让事情变得更加复杂)。所以我们就从这里开始!本着第五项要求——可维护性的精神,我们首先尝试了最简单的解决方案。

简单但无效的解决方案

由于Cursor是VS Code的分支,我们已经可以轻松访问语言服务器。在VS Code中,每个打开的文件都由一个TextModel对象表示,它在内存中存储文件的当前状态。语言服务器从这些TextModel中读取数据,而非从磁盘读取,这也是为什么它们可以在你打字时(而不仅仅是保存时)提供补全和代码检查。

假设AI对文件lib.ts进行了编辑。显然我们不能修改用户当前正在编辑的lib.ts对应的TextModel对象。不过,一个合理的想法是创建一个TextModel副本,将其与磁盘上的真实文件分离,并让AI在该副本上编辑并获取代码检查结果。这可以通过以下几行代码实现:

async getLintsForChange(origFile: ITextModel, edit: ISingleEditOperation) {
  // 创建内存中的TextModel副本,并应用AI的编辑操作
  const newModel = this.modelService.createModel(origFile.getValue(), null);
  newModel.applyEdits([edit]);
  // 等待2秒,等待语言服务器处理新的TextModel对象
  await new Promise((resolve) => setTimeout(resolve, 2000));
  // 从标记服务中读取Lint结果,内部根据语言将请求路由到正确的扩展
  const lints = this.markerService.read({ resource: newModel.uri });
  newModel.dispose();
  return lints;
}

该方案在可维护性上表现出色。通用性方面也很好,因为大多数用户已为其项目安装并配置了适当的语言扩展。而且并发性和隐私性也自然得到了满足。

问题在于独立性。虽然复制 TextModel 意味着我们没有直接修改用户正在编辑的文件,但我们仍然会告诉语言服务器(与用户正在使用的语言服务器是同一个)我们复制的文件的存在。这会导致一些问题:

  • “跳转到引用” 的结果会包含我们复制的文件;
  • 像 Go 这样默认命名空间范围涵盖多个文件的语言会报错,提示复制文件和用户正在编辑的原始文件中都存在重复的函数声明;
  • 而像 Rust 这样只有在被其他文件显式导入时才会包含文件的语言则根本不会报错。 类似的问题可能还有很多。

你可能觉得这些问题不大,但独立性对我们至关重要。如果我们稍微影响了用户正常的代码编辑体验,哪怕AI功能再好,人们(包括我自己)也不会使用Cursor。

我们还考虑了其他一些方案,最终也没有成功,比如在VS Code基础架构外生成我们自己的tsc、gopls或rust-analyzer实例,或复制VS Code扩展主机进程以便同时运行两个语言服务器实例,甚至分叉所有流行的语言服务器来支持文件的多版本,然后将这些扩展捆绑到Cursor中。

影子工作区的当前实现

我们最终将影子工作区实现为一个隐藏窗口:每当AI想要查看它编写的代码检查结果时,我们为当前工作区生成一个隐藏窗口,然后在该窗口中进行编辑,并返回代码检查结果。我们在请求之间重复使用这个隐藏窗口。这样我们几乎(*)可以实现完整的LSP-可用性,同时几乎完全满足所有要求(*后面会说明)。

简化的架构流程见图4。

架构图 图4:架构图!(我非常喜欢我们的黑板)。黄色步骤:(1) AI提出对文件的修改。(2) 修改从普通窗口的渲染进程发送到其扩展主机,再转到影子窗口的扩展主机,最后进入影子窗口的渲染进程。(3) 修改在影子窗口中应用,用户无法看到且不受影响,所有代码检查结果通过同样的方式返回。(4) AI收到代码检查结果,决定如何进行下一步的迭代。

AI在普通窗口的渲染进程中运行。当它想要查看编写的代码的检查结果时,渲染进程会要求主进程在相同的文件夹中生成一个隐藏的影子窗口。

由于Electron沙盒机制,两个渲染进程无法直接通信。我们曾考虑过重用VS Code中用于渲染进程与扩展主机通信的消息端口逻辑,来创建我们自己的IPC通信通道,但出于维护性考虑,我们选择了一个简便的方案:重用现有的渲染进程到扩展主机的IPC消息端口,然后通过独立的IPC连接在扩展主机之间进行通信。我们还顺带优化了体验:我们现在使用gRPC和buf进行通信,而不是VS Code的自定义JSON序列化逻辑。

这种设计使代码的可维护性非常高,因为新添加的代码是独立的,并且只需一行代码就能隐藏窗口(在Electron中打开窗口时,通过show: false隐藏)。该方案还自然满足通用性和隐私性要求。

幸运的是,独立性也得到了保证!新窗口完全独立于用户,因此AI可以自由地进行更改并获取其代码检查结果,用户不会察觉任何变化。

不过,影子窗口存在一个问题:新窗口会导致内存使用量翻倍。我们通过限制影子窗口中运行的扩展数量、在15分钟不活动后自动关闭窗口,并确保它是可选的,来降低这一影响。然而,这仍然对并发性提出了挑战:我们无法为每个AI生成一个新窗口。幸运的是,我们可以利用AI与人类之间的关键区别:AI可以无限期暂停而不会察觉到。在特定情况下,假设有两个AI,分别提出修改A1、A2和B1、B2,我们可以交替处理这些修改。影子窗口首先将整个文件夹状态重置为A1,获取代码检查结果并返回给A。然后将状态重置为B1,获取代码检查结果并返回给B。依此类推,处理A2和B2。在这种情况下,AI更像计算机进程(CPU会以这种方式交替处理进程),而不像人类(具有时间感知)。

综上所述,我们设计了一个简单的Protobuf API,后台的AI可以使用它来改进他们的修改,而不影响用户的任何操作。

**图5:调试模式下的影子工作区,隐藏窗口可见!**这里我们发送了一个测试请求。这是15分钟内的第一个请求,所以它首先启动了新窗口,等待语言服务器启动,并写入明显会触发代码检查错误的代码“THIS SHOULD BE A LINTER ERROR”,再等待错误返回。然后执行AI的修改,获取代码检查结果并返回给用户窗口。后续的请求(未展示)要快得多。

关于*号说明:某些语言服务器在报告代码检查之前依赖于代码写入磁盘。一个典型例子是rust-analyzer,它通过运行项目级别的cargo check来获取代码检查结果,而不是与VS Code的虚拟文件系统集成(参见此问题)。因此,除非用户使用的是已弃用的RLS扩展,否则影子工作区尚不支持Rust的LSP可用性。

实现可运行性

可运行性既复杂又有趣。目前我们主要关注短时AI任务,例如在你使用的过程中自动实现函数,而不是实现完整的PR。因此我们还未实现可运行性,但思考如何实现它非常有趣。

运行代码需要将其保存到文件系统中。许多项目还有基于磁盘的副作用(例如构建缓存和日志文件)。因此,我们不能再在用户的文件夹中启动影子窗口。为了实现所有项目的完美可运行性,我们还需要实现网络级隔离,但现在我们专注于实现磁盘隔离。

最简单的方案:cp -r

最简单的方案是递归地将用户的文件夹复制到/tmp位置,然后应用AI的修改,保存文件并在该位置运行代码。对于不同AI的下一次修改,我们可以执行rm -rf,然后再次调用cp -r,以确保影子工作区与用户的工作区保持同步。

问题在于速度:cp -r非常慢。为了运行项目,我们不仅需要复制源代码,还需要复制所有支持构建的相关文件。具体来说,我们需要复制JavaScript项目中的node_modules、Python项目中的venv以及Rust项目中的target。这些文件夹通常非常庞大,即使是中等规模的项目,这也意味着简单的cp -r方法难以奏效。

符号链接、硬链接和写时复制

复制和创建大型文件夹结构并不一定会非常慢!一个现成的例子是bun,它通常能够在秒级时间内将缓存的依赖安装到node_modules中。在Linux上,它使用硬链接,因为不需要实际的数据移动,速度很快。在macOS上,它使用clonefile系统调用,这是一个较新的功能,可以执行文件或文件夹的写时复制。

遗憾的是,对于我们中等规模的单体代码库,即使使用cp -c加clonefile,也需要45秒才能完成。这对于在每次影子工作区请求之前执行来说,还是太慢了。硬链接的问题在于,影子文件夹中的任何操作可能会意外修改原始代码库中的真实文件。符号链接也存在类似的问题,并且它们通常需要额外的配置(例如Node.js中的--preserve-symlinks标志)。

我们可以想象,clonefile(甚至普通的cp -r)如果配合某些巧妙的机制,避免每次请求前重新复制整个文件夹,可能是可行的。为了确保准确性,我们需要监控用户文件夹自上次完整复制以来的所有文件更改,以及影子文件夹中的所有更改。每次请求前,撤销影子文件夹中的更改并重新应用用户文件夹中的更改。每当这些更改历史过于庞大难以追踪时,我们可以重新进行完整复制并重置状态。这种方式或许可行,但感觉容易出错,维护也会很繁琐,特别是对于一个看似简单的问题来说。

我们真正需要的:内核级别的文件夹代理

我们真正想要的是简单的解决方案:我们希望影子文件夹A'看起来对所有使用常规文件系统API的应用程序来说与用户文件夹A完全相同,但能够快速配置一小部分覆盖文件,这些文件的内容从内存中读取。同时,我们希望对影子文件夹A'的任何写操作都写入到内存中的覆盖存储,而不是写入磁盘。简而言之,我们需要一个带有可配置覆盖的代理文件夹,并且我们愿意将这些覆盖表完全保存在内存中。然后,我们可以在这个代理文件夹中启动影子窗口,从而实现完美的磁盘级独立性。

关键是我们需要内核级别的支持,以便正在运行的代码可以继续调用read和write系统调用,而不需要做任何更改。一个解决方案是创建一个内核扩展,它在内核的虚拟文件系统中注册为影子文件夹的后端,并实现这些简单的行为。

在Linux上,我们可以通过FUSE(“用户态文件系统”)在用户级别实现这一点。FUSE是大多数Linux发行版中默认存在的内核模块,它将文件系统调用代理到用户态进程。这使得实现文件夹代理更加简单。一个文件夹代理的简单实现可以如下所示,这里以C++为例。

首先,我们导入负责与FUSE内核模块通信的用户态FUSE库。我们还定义了目标文件夹(用户的文件夹)和内存中的覆盖映射。

#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
// other includes...
using namespace std;
// the proxied folder that we do not want to modify
string target_folder = "/path/to/target/folder";
// the in-memory overrides to apply
unordered_map<string, vector<char>> overrides;

然后,通过我们的自定义读取函数来检查覆盖是否包含该路径,如果没有,就从目标文件夹中读取。

int proxy_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
    // check if the path is in the overrides
    string path_str(path);
    if (overrides.count(path_str)) {
        const vector<char>& content = overrides[path_str];
        // if so, return the content of the override
        if (offset < content.size()) {
            if (offset + size > content.size())
                size = content.size() - offset;
            memcpy(buf, content.data() + offset, size);
        } else {
            size = 0;
        }
        return size;
    }
    // otherwise, open and read the file from the proxied folder
    string fullpath = target_folder + path;
    int fd = open(fullpath.c_str(), O_RDONLY);
    if (fd == -1)
        return -errno;
    int res = pread(fd, buf, size, offset);
    if (res == -1)
        res = -errno;
    close(fd);
    return res;
}

我们的自定义写函数只是写 overrides 映射。

int proxy_write(const char *path, const char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
    // always write to the overrides
    string path_str(path);
    vector<char>& content = overrides[path_str];
    if (offset + size > content.size()) {
        content.resize(offset + size);
    }
    memcpy(content.data() + offset, buf, size);
    return size;
}

最后,我们用FUSE注册我们的自定义函数。

int main(int argc, char *argv[])
{
    struct fuse_operations operations = {
        .read = proxy_read,
        .write = proxy_write,
    };
    return fuse_main(argc, argv, &operations, NULL);
}

一个真正的实现需要完整地实现 FUSE API,包括 readdir、getattr 和 lock 等功能,但这些功能的实现会与上面提到的非常相似。对于每个新的代码检查请求,我们都可以简单地将 overrides 映射重置为特定 AI 的编辑内容,这个操作是瞬间完成的。如果我们想要确保内存不会爆满,我们还可以将 overrides 映射保存在磁盘上(这需要一些额外的簿记工作)。

如果我们能完全控制环境,最理想的方案是实现一个原生内核模块,这样可以避免FUSE带来的用户态与内核态的额外切换开销。

但现实问题:封闭系统

在Linux上,FUSE文件夹代理可以很好地运行,但我们大多数用户使用的是macOS或Windows,而这两者都没有内置的FUSE实现。不幸的是,发布内核扩展也是不可行的:在搭载Apple Silicon的Mac上,用户必须通过按住特殊按键进入恢复模式并重启系统,然后将安全等级降至“Reduced Security”才能安装内核扩展,这显然无法推广。

由于FUSE部分需要在内核中运行,第三方的FUSE实现如macFUSE也面临着同样难以安装的问题。

有人尝试通过创意绕过这个限制。例如,利用macOS原生支持的网络文件系统(如NFS或SMB),在其上运行FUSE API。有一个基于NFS的开源FUSE API本地服务器原型xetdata/nfsserve,以及一个闭源项目macOS-FUSE-t支持NFS和SMB的后端。

问题解决了吗?并没有…… 文件系统比简单的读写和列出文件要复杂得多!例如,Cargo会抱怨NFSv3不支持文件锁。

NFSv3不支持文件锁 图6:由于NFSv3不支持文件锁,Cargo执行失败……

macOS-FUSE-t基于NFSv4,后者支持文件锁,不过该GitHub仓库只包含三个非源码文件(Attributions.txt、License.txt、README.md),且由一个为此目的创建的账号管理。显然,我们不能将随机的二进制文件分发给用户…… 而且,还有与Apple内核bug相关的一些根本性问题影响了NFS/SMB方案的可行性。

我们剩下的选择是什么?要么采用新创意,要么依靠一些“政治”操作!Apple已经花了十多年时间逐步淘汰内核扩展,转而开放更多用户态API(如DriverKit),并且他们对旧文件系统的支持也已转移到用户态。他们开源的MS-DOS代码中提到了一个名为FSKit的私有框架,这听起来非常有潜力!也许通过一些“政治”手段,我们可以推动Apple将FSKit开放给外部开发者(或者他们已经计划这样做?),这样我们可能就能解决macOS上的可运行性问题。

未解问题

正如我们看到的,允许AI在后台迭代代码的看似简单的问题实际上非常复杂。影子工作区项目仅用了一个人一周时间,以满足我们向 AI 展示代码检查结果的迫切需求。未来,我们计划扩展它,以解决代码可运行性问题。目前还有一些悬而未决的问题:

  1. 有没有一种办法实现我们设想的简单代理文件夹,而不需要创建内核扩展或使用FUSE API?FUSE解决的是一个更大的问题(任何类型的文件系统),因此推测macOS或Windows上可能存在一些不常见的API,它们可以实现我们的文件夹代理,但不适用于FUSE的广泛应用场景。
  2. 在Windows上,代理文件夹的实现是怎样的?像WinFsp这样的工具是否可以直接使用,还是会有安装、性能或安全方面的问题?我主要时间花在了研究macOS的代理文件夹实现上。
  3. 也许可以通过macOS的DriverKit模拟一个虚拟USB设备作为代理文件夹?我对此并不确定,还没有深入研究这个API的细节。
  4. 如何实现网络级别的独立性?特别是当AI想要调试分布在三个微服务之间的集成测试时。可能需要一种类似虚拟机的方案,尽管这需要更多工作来确保整个环境及所有软件的等效性。
  5. 能否在不增加用户配置负担的情况下,从用户的本地工作区创建一个完全相同的远程工作区?在云端,我们可以直接使用FUSE(甚至可以根据需要用内核模块来提高性能),而不需要进行“政治”操作,并且我们还可以保证用户不额外占用内存,实现完全独立性。对于不太在意隐私的用户,这是个不错的选择。一个初步的想法是通过观察系统来自动推断Docker容器的配置(或许可以结合编写脚本检测系统上运行的内容,利用语言模型生成Dockerfile)。

如果你对这些问题有好的想法,请发邮件至arvid@anysphere.inc。另外,如果你对这些工作感兴趣,我们正在招聘。