- Published on
影子工作区:在后台迭代代码
- Authors
- Name
- 风哥AI
这是一个失败的食谱:将几个相关文件粘贴到 Google 文档中,将链接发送给你最喜欢的 p60 软件工程师,他们对你的代码库一无所知,并要求他们在文档中完全正确地实现你的下一个 PR。
让 AI 做同样的事情,它也会如预期般失败。
1
现在,相反地,给他们远程访问你的开发环境的权限,能够查看 lint、跳转到定义并运行代码,你可能会期待他们能提供一些帮助。
图 1:你更愿意在代码编辑器中调试你的 pin-boxed 未来生命周期,还是在 Google 文档中?AI 也是如此。
我们相信,让 AI 编写更多代码的一个关键因素是能够在你的开发环境中进行迭代。
2
但天真地让 AI 在你的文件夹中自由运行会导致混乱:想象一下编写一个推理密集的函数,结果 AI 将其覆盖,或者尝试运行你的程序,结果 AI 插入了无法编译的代码。为了真正提供帮助,AI 的迭代需要在后台进行,而不影响你的编码体验。
为此,我们在 Cursor 中实现了我们所称的 影子工作区。
3
在这篇博客文章中,我将首先概述我们的设计标准,然后描述在撰写时 Cursor 中存在的实现(一个隐藏的 Electron 窗口)以及我们未来打算如何发展它(一个内核级文件夹代理)。
图 2:Cursor 中影子工作区的隐藏设置。目前为自愿选择。
设计标准
我们希望影子工作区实现以下目标:
- LSP 可用性:AI 应该能够看到其更改的 lint,能够跳转到定义,并且更一般地能够与 (LSP) 的所有部分进行交互。
- 可运行性:AI 应该能够运行其代码并查看输出。
我们最初专注于 LSP 可用性。
这些目标应在以下要求的前提下实现:
- 独立性:用户的编码体验必须不受影响。
- 隐私:用户的代码应该是安全的(例如,全部保存在本地)。
- 并发性:多个 AI 应该能够并发地进行工作。
- 通用性:它应该适用于所有语言和所有工作区设置。
- 可维护性:它应该以尽可能少且可隔离的代码编写。
- 速度:任何地方都不应有长达一分钟的延迟,并且应有足够的吞吐量以支持数百个 AI 分支。
这些标准中的许多反映了为超过十万用户构建代码编辑器的现实。我们真的不想对任何人的编码体验产生负面影响。
实现 LSP 可用性
让 AI 获取其编辑的 lint 是在固定底层语言模型的情况下提高代码生成性能的最有效方法之一。lint 不仅可以将工作代码从 90% 提升到 100%,而且在上下文受限的情况下也非常有帮助,当 AI 可能需要对第一次调用的方法或服务做出有根据的猜测时。lint 可以帮助识别 AI 需要请求更多信息的地方。
图 3:AI 通过迭代 lint 实现一个函数。
LSP 可用性也比可运行性更简单,因为几乎所有语言服务器都可以在未写入文件系统的文件上操作(正如我们稍后将看到的,涉及文件系统会使事情变得更加复杂)。所以我们从这里开始!本着我们第五个要求——可维护性的精神,我们首先尝试了最简单的解决方案。
不起作用的简单解决方案
Cursor 是 VS Code 的一个分支,这意味着我们已经非常容易地访问语言服务器。在 VS Code 中,每个打开的文件由一个 TextModel
对象表示,该对象在内存中存储文件的当前状态。语言服务器从这些文本模型对象中读取,而不是从磁盘中读取,这就是它们能够在你输入时提供补全和 lint 的原因(而不仅仅是在你保存时)。
假设 AI 对文件 lib.ts
进行了编辑。我们显然不能修改与 lib.ts
对应的现有 TextModel
对象,因为用户可能同时在编辑它。然而,一个听起来合理的想法是创建一个 TextModel
对象的副本,将副本与磁盘上的任何真实文件分离,并让 AI 从该对象中进行编辑和获取 lint。这可以通过以下 6 行代码实现。
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
实例,
4
复制所有 VS Code 扩展运行的扩展主机进程,以便我们可以运行每个语言服务器扩展的两个副本,
5
以及分叉所有流行的语言服务器以支持多个不同版本的文件,然后将这些扩展打包到 Cursor 中。
6
当前影子工作区实现
我们最终将影子工作区实现为一个隐藏窗口:每当 AI 想要查看其编写代码的 lint 时,我们为当前工作区生成一个隐藏窗口,然后在该窗口中进行编辑,并报告 lint。我们在请求之间重用隐藏窗口。这几乎*提供了完整的 LSP 可用性,同时几乎完全满足所有要求。带星号的内容稍后会解决。
简化的架构图如图 4 所示。
图 4:架构图! (展示了我非常喜欢的黑板。)黄色步骤:(1)AI 提出对文件的编辑。 (2)编辑从正常窗口的渲染进程发送到其扩展主机,然后发送到影子窗口的扩展主机,最后发送到影子窗口的渲染进程。 (3)编辑在影子窗口中应用,隐藏并独立于用户,所有 lint 以相同方式返回。 (4)AI 接收 lint 并决定如何进行迭代。
AI 在正常窗口的渲染进程中运行。当它想要查看其编写代码的 lint 时,渲染进程请求主进程在同一文件夹中生成一个隐藏的影子窗口。
7
由于 Electron 的沙箱机制,这两个渲染进程无法直接相互通信。我们考虑的一个选项是重用 VS Code 实现的仔细消息端口创建逻辑,以便让渲染进程与扩展主机进程进行通信,并利用它创建我们自己的消息端口 IPC,在正常窗口和影子窗口之间。
8
出于对可维护性负担的担忧,我们选择了一种黑客方式:我们重用现有的消息端口 IPC,从渲染进程到扩展主机,然后通过独立的 IPC 连接从扩展主机到扩展主机进行通信。在这里,我们还悄悄加入了一项生活质量的改进:我们现在可以使用 gRPC(我们非常喜欢)进行通信,而不是 VS Code 的自定义且有些脆弱的 JSON 序列化逻辑。
这个设置在自动上是相当可维护的,因为添加的代码与其他代码独立,隐藏窗口所需的核心代码仅一行(在 Electron 中打开窗口时,可以提供参数 show: false
来隐藏它)。它轻松满足通用性和隐私。
幸运的是,独立性也得到了满足!新窗口与用户完全独立,因此 AI 可以自由进行任何更改并获取相应的 lint。用户不会注意到任何事情。
9
影子窗口有一个问题:新窗口天真地带来了 2 倍的内存使用量。我们通过限制可以在影子窗口中运行的扩展、在 15 分钟不活动后自动关闭它,并确保它是自愿选择的来减少影响。尽管如此,它对并发性构成了挑战:我们不能简单地为每个 AI 启动一个新的影子窗口。幸运的是,在这里我们可以利用 AI 和人类之间的一个关键区别:AI 可以在不注意的情况下暂停无限长的时间。特别是,如果你有两个 AI,AA 和 BB,分别提出编辑 A1A_1 后跟 A2A_2 和 B1B_1 后跟 B2B_2,你可以交错这些编辑。影子窗口首先将整个文件夹状态重置为 A1A_1,并获取 lint 并将其返回给 AA。然后,它将整个文件夹状态重置为 B1B_1,并获取 lint 并将其返回给 BB。如此反复进行 A2A_2 和 B2B_2。从这个意义上说,AI 更像计算机进程(CPU 也会在不注意的情况下交错处理)而不是人类(人类有内在的时间感)。
综合考虑,我们得出结论,我们的后台 AI 可以用来完善他们的编辑,而不会对用户产生任何影响。
10
图 5:调试模式下的影子工作区,隐藏窗口可见!在这里我们发送一个测试请求。这是 15 分钟内的第一个请求,因此它首先启动新窗口,并通过编写显然应该返回 lint 错误的代码(“这应该是一个 lint 错误”)来等待语言服务器启动,并等待实际返回错误。然后,它执行 AI 编辑,获取 lint,并将其返回给用户的窗口。后续请求(此处未显示)要快得多。
承诺的星号:一些语言服务器依赖于在报告 lint 之前将代码写入磁盘。主要的例子是 rust-analyzer
语言服务器,它简单地运行项目级的 cargo check
来获取 lint,并且不与 VS Code 虚拟文件系统集成(参见参考)。因此,影子工作区尚不支持 Rust 的 LSP 可用性,除非用户使用已弃用的 RLS
扩展。
实现可运行性
可运行性是事情变得既有趣又复杂的地方。我们目前专注于 Cursor 的短时间尺度 AI——例如,在你使用它们的同时在后台为你实现函数,
11
而不是实现整个 PR——因此我们尚未实现可运行性。尽管如此,思考如何实现它仍然很有趣。
运行代码需要将其保存到文件系统中。
12
许多项目还会有基于磁盘的副作用(想想构建缓存和日志文件)。因此,我们不能再在与用户相同的文件夹中启动影子窗口。为了实现所有项目的完美可运行性,我们还需要网络级隔离,但目前我们专注于实现磁盘隔离。
cp -r
最简单的想法:最简单的想法是将用户的文件夹递归复制到 /tmp
位置,然后应用 AI 编辑,保存文件,并在那里运行代码。对于不同 AI 的下一个编辑,我们将执行 rm -rf
,然后进行新的 cp -r
调用,以确保影子工作区与用户的工作区保持同步。
问题在于速度:cp -r
实在太慢。需要记住的是,为了能够运行一个项目,我们不仅需要复制源代码,还需要所有支持的构建相关文件。具体来说,我们需要复制 JavaScript 项目的 node_modules
、Python 项目的 venv
和 Rust 项目的 target
。这些通常是巨大的文件夹,即使对于中等大小的项目来说,这也意味着天真的 cp -r
方法的终结。
符号链接、硬链接、写时复制
复制和创建大型文件夹结构不必超级慢!存在的证明是,它通常在将缓存依赖项安装到 node_modules
时需要不到一秒的时间。在 Linux 上,他们使用硬链接,这很快,因为没有实际的数据移动。在 macOS 上,他们使用相对较新的,它执行文件或文件夹的写时复制。
可悲的是,对于我们中等大小的单体库,即使是 cp -c
clonefile 也需要 45 秒才能完成。这对于在每个影子工作区请求之前运行来说太慢了。硬链接是可怕的,因为你在影子文件夹中运行的任何内容可能会意外修改原始存储库中的真实文件。符号链接同样如此,并且它们还有一个额外的问题,即不被透明处理,这意味着它们通常需要额外的配置(例如)。
人们可以想象,如果结合一些聪明的会计方案来防止在每个请求之前重新复制文件夹,clonefile(甚至普通的 cp -r
)可以正常工作。为了确保正确性,我们需要监控用户文件夹自上次完整复制以来的所有文件更改,以及复制文件夹中的所有文件更改,并在每个请求之前撤销后者并重放前者。每当任一侧的更改历史变得过于庞大而无法跟踪时,我们可以进行新的完整复制并重置状态。这可能可行,但感觉容易出错、脆弱,并且坦率地说,对于实现听起来如此简单的事情来说有点丑陋。
我们真正想要的:内核级文件夹代理
我们真正想要的很简单:我们希望影子文件夹 A′A\prime 对所有使用常规文件系统 API 的应用程序看起来与用户的文件夹 AA 完全相同,并能够快速配置一小组覆盖文件,其内容则从内存中读取。我们还希望对文件夹 A′A\prime 的任何写入都写入内存覆盖存储,而不是磁盘。简而言之,我们希望一个具有可配置覆盖的代理文件夹,并且我们乐意将覆盖表完全保留在内存中。然后,我们可以在这个代理文件夹中生成我们的影子窗口,并实现完美的磁盘级独立性。
至关重要的是,我们需要内核级支持来实现文件夹代理,以便任何运行的代码可以继续调用 read
和 write
系统调用而无需任何更改。一种方法是创建一个内核扩展
13
,注册为内核虚拟文件系统中影子文件夹的后端,并实现上述简单行为。
在 Linux 上,我们可以在用户级别而不是内核级别执行此操作,使用(“用户空间中的文件系统”)。FUSE 是一个内核模块,默认情况下在大多数 Linux 发行版中已经存在,并将文件系统调用代理到用户级进程。这使得实现文件夹代理变得更加简单。文件夹代理的玩具实现可能如下所示,这里以 C++ 形式呈现。
首先,我们导入用户级 FUSE 库,该库负责与 FUSE 内核模块进行通信。我们还定义目标文件夹(用户的文件夹)和内存中覆盖的映射。
#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
// 其他包含...
using namespace std;
// 我们不想修改的代理文件夹
string target_folder = "/path/to/target/folder";
// 要应用的内存覆盖
unordered_map<string, vector<char>> overrides;
然后,我们定义自定义的 read
函数,以检查覆盖是否包含路径,如果没有,则从目标文件夹中读取。
int proxy_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
// 检查路径是否在覆盖中
string path_str(path);
if (overrides.count(path_str)) {
const vector<char>& content = overrides[path_str];
// 如果是,则返回覆盖的内容
if (offset < content.size()) {
if (offset + size > content.size())
size = content.size() - offset;
memcpy(buf, content.data() + offset, size);
} else {
size = 0;
}
return size;
}
// 否则,从代理文件夹打开并读取文件
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;
}
我们的自定义 write
函数简单地写入覆盖映射。
int proxy_write(const char *path, const char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
// 始终写入覆盖
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
,但这些函数将与上述函数非常相似。对于每个新的 lint 请求,我们可以简单地将覆盖映射重置为仅该特定 AI 的编辑,这一过程是瞬时的。如果我们想要保证不会出现内存膨胀,我们还可以将覆盖映射保存在磁盘上(需要一些额外的记账工作)。
在对环境的完美控制下,我们可能希望将其实现为本地内核模块,以避免 FUSE 带来的额外用户-内核上下文切换开销。
14
...但是:围墙花园
对于 Linux,FUSE 文件夹代理工作得很好,但我们的大多数用户使用 macOS 或 Windows,这两者都没有内置的 FUSE 实现。不幸的是,发布内核扩展也是不可能的:在带有 Apple Silicon 的 Mac 上,用户安装内核扩展的唯一方法是重启计算机,同时按住特殊键进入恢复模式,然后降级到“减少安全性”。无法发布!
由于 FUSE 部分需要在内核内部运行,第三方 FUSE 实现也面临着同样的无法让用户安装的问题。
人们曾尝试围绕这一限制进行创造性思考。一种方法是采用 macOS 原生支持的基于网络的文件系统(例如,或),并在其下方放置 FUSE API。有一个开源的概念证明本地服务器,具有基于 NFS 的 FUSE 类 API,托管在,并且该闭源项目支持基于 NFS 和 SMB 的后端。
问题解决了?并不完全... 文件系统比仅仅读取、写入和列出文件要复杂得多!在这里,Cargo 抱怨,因为早期版本的 NFS,xetdata/nfsserve
实现构建在其上,不支持文件锁定。
图 6:Cargo 失败,因为 NFSv3 不支持文件锁定...
MacOS-FUSE-t 基于 NFSv4,它确实支持文件锁定,但 GitHub 仓库中只包含三个非源文件(Attributions.txt、License.txt、README.md),并且是由一个可疑的单一用途用户名 macos-fuse-t
创建的,没有进一步的信息。显然,我们不能将随机二进制文件发布给我们的用户... 开放问题也表明 NFS/SMB 基于的方法存在一些更根本的问题,主要与 Apple 有关。
我们剩下什么?要么是新的创造性方法,
15
要么是... 政治!苹果十年来逐步淘汰内核扩展的过程使他们开放了越来越多的用户级 API(例如),并且他们对旧文件系统的内置支持最近也得到了增强。他们的开源 MS-DOS 代码引用了一个名为 FSKit
的私有框架,这听起来非常有前景!感觉有可能通过一点政治,我们可以让他们最终确定并发布 FSKit
给外部开发者(或者他们可能已经在计划这样做?),在这种情况下,我们可能会找到 macOS 的可运行性问题的解决方案。
待解决的问题
正如我们所看到的,让 AI 在后台迭代代码的看似简单的问题实际上相当复杂。影子工作区是一个为期一周、一个人的项目,旨在创建一个实现,以解决我们需要向 AI 显示 lint 的紧迫需求。在未来,我们计划将其扩展以解决可运行性问题。一些待解决的问题:
- 有没有其他方法可以实现我们所想的简单代理文件夹,而不创建内核扩展或使用 FUSE API?FUSE 试图解决一个更大的问题(任何类型的文件系统),因此感觉有可能在 macOS 和 Windows 上存在一些晦涩的 API,可以为我们的文件夹代理工作,但不适用于通用的 FUSE 实现。
- 代理文件夹在 Windows 上的故事究竟是什么样的?像这样是否可以正常工作,还是存在安装、性能或安全问题?我花了大部分时间研究如何在 macOS 上实现文件夹代理。
- 也许可以在 macOS 上使用 DriverKit 模拟一个假 USB 设备来充当代理文件夹?我对此表示怀疑,但我还没有足够仔细地研究 API,以自信地说这不可能。
- 我们如何实现网络级独立性?一个特别需要考虑的情况是,当 AI 想要调试一个集成测试时,代码分布在三个微服务之间。
16
我们可能想做一些更像虚拟机的事情,尽管这需要更多的工作来确保整个环境设置和所有安装软件的等效性。5. 有没有办法从用户的本地工作区创建一个相同的远程工作区,而用户所需的设置尽可能少?在云中,我们可以开箱即用地使用 FUSE(或者如果出于性能原因需要,也可以使用内核模块),而无需进行任何政治,我们还可以保证用户没有额外的内存使用和完全的独立性。对于那些对隐私不太关心的用户来说,这可能是一个不错的替代方案。一个原型想法是通过观察系统(也许结合编写脚本来检测机器上正在运行的内容,并使用语言模型编写 Dockerfile)来自动推断某种 Docker 容器。
如果你对这些问题有好的想法,请通过电子邮件与我联系。如果你想参与这样的工作。