Author image

作者Arvid

影子工作区:在后台迭代代码

隐藏的 Electron 窗口和内核级文件夹代理,允许 AI 在不影响用户的情况下迭代代码。

这是一个失败的方案:将一些相关文件粘贴到 Google Docs 中,将链接发送给您最喜欢的 p60 软件工程师(他们对您的代码库一无所知),并要求他们在文档中完全正确地实现您的下一个 PR。

让 AI 做同样的事情,它也会(可以预见地)失败。1

现在,改为授予他们对您的开发环境的远程访问权限,让他们能够查看 lint、跳转到定义并运行代码,您可能真的可以期待他们提供一些帮助。

图 1:您是更愿意在代码编辑器中调试您打包的未来生命周期,还是在 Google Docs 中调试?AI 也是。

我们认为,让 AI 编写更多代码的关键之一是能够在您的开发环境中进行迭代。2 但是,让 AI 在您的文件夹中随意运行会导致混乱:想象一下,您正在编写一个需要大量推理的函数,但 AI 却覆盖了它,或者您尝试运行程序,但 AI 却插入了无法编译的代码。为了真正提供帮助,AI 的迭代需要在后台进行,而不会影响您的编码体验。

为了实现这一点,我们在 Cursor 中实现了我们称为影子工作区的功能。3 在这篇博文中,我将首先概述我们的设计标准,然后描述 Cursor 在撰写本文时存在的实现(一个隐藏的 Electron 窗口),以及我们打算在未来将其发展到何种程度(一个内核级文件夹代理)。

图 2:Cursor 中影子工作区的隐藏设置。目前为选择加入。

设计标准

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

  1. LSP 可用性:AI 应该能够看到其更改产生的 lint,能够跳转到定义,以及更普遍地能够与 语言服务器协议 (LSP) 的所有部分进行交互。

  2. 可运行性:AI 应该能够运行其代码并查看输出。

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

在满足以下要求的前提下,应实现这些目标

  1. 独立性:用户编码体验必须不受影响。

  2. 隐私性:用户代码必须安全(例如,使其全部本地化)。

  3. 并发性:多个 AI 应该能够同时执行其工作。

  4. 通用性:它应该适用于所有语言和所有工作区设置。

  5. 可维护性:它应该用尽可能少且尽可能隔离的代码编写。

  6. 速度:任何地方都不应该出现长达一分钟的延迟,并且应该有足够的吞吐量来处理数百个 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 行代码来实现。

此解决方案在可维护性方面显然非常出色。它在通用性方面也很出色,因为大多数人已经安装并配置了其项目所需的正确语言特定扩展。并发性和隐私性也易于满足。

问题在于独立性。虽然创建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。出于对可维护性负担的担忧,我们选择了一种 hack 方法:我们重复使用从渲染器进程到扩展宿主的现有消息端口 IPC,然后使用独立的 IPC 连接在扩展宿主之间进行通信。在那里,我们还偷偷地进行了一项质量改进:我们现在可以使用 gRPC 和 buf(我们喜欢它)来通信,而不是 VS Code 的自定义且有些脆弱的 JSON 序列化逻辑。

此设置在自动化的同时非常易于维护,因为添加的代码独立于其他代码,并且隐藏窗口所需的核心代码只有一行(在 Electron 中打开窗口时,您可以提供参数 `show: false` 来隐藏它)。它可以轻松满足普遍性和隐私性。

幸运的是,独立性也得到了满足!新窗口完全独立于用户,因此 AI 可以自由地进行任何他们想要的更改并获取相应的 lint。用户不会注意到任何事情 9

影子窗口有一个问题:新窗口天真地导致内存使用量增加 2 倍。我们通过限制在影子窗口中运行的扩展、在 15 分钟的空闲时间后自动将其杀死以及确保它是可选的来减少此影响。尽管如此,它对并发性提出了挑战:我们不能简单地为每个 AI 生成一个新的影子窗口。幸运的是,在这里我们可以利用 AI 和人类之间的一个关键区别因素:AI *可以*暂停无限长的时间而不会有任何察觉。特别是,如果您有两个 AI,AABB,分别提议编辑 A1A_1 后跟 A2A_2B1B_1 后跟 B2B_2,您可以交错这些编辑。影子窗口首先将整个文件夹状态重置为 A1A_1,并获取 lint 并将其返回给 AA。然后,它将整个文件夹状态重置为 B1B_1,并获取 lint 并将其返回给 BB。依此类推,使用 A2A_2B2B_2。从这个意义上说,AI 更类似于计算机进程(CPU 也通过这种方式交错它们而不会被察觉),而不是人类(他们具有内在的时间感)。

综上所述,我们得到了一个 简单的 Protobuf API,我们的后台 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` 方法将走向终结。

复制和创建大型文件夹结构不必非常缓慢!一个存在证明是 bun,它通常在将缓存的依赖项安装到 `node_modules` 中时花费不到一秒的时间。在 Linux 上,它们使用硬链接,这很快,因为没有实际的数据移动。在 macOS 上,它们使用 `clonefile` 系统调用,这是一个相对较新的添加,它执行文件或文件夹的写时复制。

遗憾的是,对于我们中等规模的单体仓库,即使使用cp -c克隆文件也需要 45 秒才能完成。这太慢了,无法在每次影子工作区请求之前运行。硬链接很危险,因为在影子文件夹中运行的任何操作都可能意外修改原始存储库中的真实文件。符号链接也类似,并且还存在不被透明处理的额外问题,这意味着它们通常需要额外的配置(例如,Node.js 的--preserve-symlinks标志)。

可以想象一个克隆文件(甚至是一个普通的cp -r)配合一些巧妙的会计方案,以避免在每次请求之前都必须重新复制文件夹。为了确保正确性,我们需要监控用户文件夹自上次完整复制以来的所有文件更改,以及已复制文件夹中的所有文件更改,并在每次请求之前撤消后者并重放前者。每当任一侧的更改历史记录变得太大而无法跟踪时,我们可以进行新的完整复制并重置状态。这可能有效,但感觉容易出错、脆弱,坦率地说,为了实现听起来如此简单的事情,它有点丑陋。

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

我们真正想要的是简单的:我们希望影子文件夹AA\prime 对使用常规文件系统 API 的所有应用程序来说,看起来与用户的文件夹AA 完全相同,并且能够快速配置一小部分覆盖文件,其内容改为从内存中读取。我们还希望对文件夹AA\prime 的任何写入都写入内存中的覆盖存储,而不是写入磁盘。简而言之,我们想要一个具有可配置覆盖的代理文件夹,并且我们很乐意将覆盖表完全保留在内存中。然后,我们可以在此代理文件夹内生成我们的影子窗口,并实现完美的磁盘级独立性。

至关重要的是,我们需要内核级对文件夹代理的支持,以便任何运行的代码都可以继续调用readwrite系统调用而无需任何更改。一种方法是创建一个内核扩展13,它在内核的虚拟文件系统中将自身注册为影子文件夹的后端,并实现上面概述的简单行为。

在 Linux 上,我们可以使用FUSE(“用户空间中的文件系统”)在用户级执行此操作。FUSE 是一个内核模块,默认情况下已存在于大多数 Linux 发行版中,并且将文件系统调用代理到用户级进程。这使得实现文件夹代理变得更加简单。文件夹代理的玩具实现可能如下所示,这里以 C++ 呈现。

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

然后,我们定义自定义的read函数来检查覆盖是否包含路径,如果未包含,则只需从目标文件夹读取。

我们的自定义write函数仅写入覆盖映射。

最后,我们将自定义函数注册到 FUSE。

一个真正的实现需要实现整个 FUSE API,包括 readdirgetattrlock 等,但这些函数与上述函数非常相似。对于每个新的 lint 请求,我们只需将 overrides 映射重置为特定 AI 的编辑内容,这可以立即完成。如果我们想避免内存膨胀,我们也可以将 overrides 映射保存在磁盘上(需要一些额外的簿记工作)。

在完全控制环境的情况下,我们可能希望将其作为原生内核模块来实现,以避免 FUSE 额外用户-内核上下文切换带来的开销。14

...但是:封闭花园

对于 Linux,FUSE 文件夹代理运行良好,但我们的大多数用户使用 macOS 或 Windows,这两个系统都没有内置的 FUSE 实现。不幸的是,发布内核扩展也是不可能的:在搭载 Apple 芯片的 Mac 上,用户安装内核扩展的唯一方法是在开机时按住某个特殊键进入恢复模式,然后降级到“降低安全性”。这无法发布!

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

已经有人尝试对这个限制进行创造性的解决。一种方法是采用 macOS 本身支持的基于网络的文件系统(例如,NFSSMB),并在其下方放置一个 FUSE API。有一个开源的概念验证本地服务器,其上构建了一个类似于 FUSE 的 API,该 API 基于 NFS,托管在 xetdata/nfsserve,而闭源项目 macOS-FUSE-t 支持基于 NFS 和 SMB 的后端。

问题解决了吗?还没有……文件系统比仅仅读取、写入和列出文件要复杂得多!在这里,Cargo 抱怨是因为早期版本的 NFS(xetdata/nfsserve 实现所基于的版本)不支持文件锁定。

图 6:Cargo 失败,因为 NFSv3 不支持文件锁定……

MacOS-FUSE-t 基于 NFSv4,它确实支持文件锁定,但 GitHub 存储库除了三个非源文件(Attributions.txt、License.txt、README.md)之外什么也没有,并且由一个 GitHub 帐户创建,该帐户的用户名非常可疑,只有一个目的,即 macos-fuse-t,没有任何其他信息。显然,我们不能向我们的用户发布随机二进制文件……未解决的问题也表明基于 NFS/SMB 的方法存在一些更基本的问题,主要与 Apple 内核 错误 相关。

我们剩下什么了?要么是一种新的创造性方法,15 要么是……政治!苹果长达十年的逐步淘汰内核扩展之旅导致他们开放了越来越多的用户级 API(例如,DriverKit),并且他们对旧文件系统的内置支持最近已切换到用户空间。他们的开源 MS-DOS 代码在此处引用了一个名为 FSKit 的私有框架此处,这听起来很有希望!感觉有可能通过一点政治手段,我们可以让他们最终确定并向外部开发人员发布 FSKit(或者他们可能已经在计划中?),在这种情况下,我们可能也找到了 macOS 运行问题的解决方案。

开放性问题

正如我们所见,允许 AI 在后台迭代代码这个看似简单的问题实际上非常复杂。影子工作区是一个为期一周的个人项目,旨在创建一个实现来解决我们当时遇到的向 AI 显示 lint 的迫切需求。将来,我们计划扩展它以解决运行问题。一些悬而未决的问题

  1. 是否有其他方法可以实现我们正在考虑的简单代理文件夹,而无需创建内核扩展或使用 FUSE API?FUSE 试图解决一个更大的问题(任何类型的文件系统),因此似乎有可能 macOS 和 Windows 上存在一些晦涩的 API,这些 API 适用于我们的文件夹代理,但不适用于通用 FUSE 实现。

  2. Windows 上的代理文件夹到底是什么样的?像 WinFsp 这样的东西是否可以正常工作,或者在安装、性能或安全方面是否存在问题?我大部分时间都在研究如何在 macOS 上实现文件夹代理。

  3. 也许有一种方法可以在 macOS 上使用 DriverKit 并模拟一个假 USB 设备来充当代理文件夹?我对此表示怀疑,但我还没有深入研究 API 以自信地断言这不可能。

  4. 我们如何实现网络级独立性?需要考虑的一种特定情况是当 AI 想要调试一个集成测试时,其中代码分布在三个微服务之间。16 我们可能希望做一些更类似于 VM 的事情,尽管这需要更多工作来确保整个环境设置和所有已安装软件的等效性。

  5. 是否有办法从用户的本地工作区创建相同的远程工作区,而用户无需进行太多设置?在云端,我们可以开箱即用地使用 FUSE(或者如果出于性能原因需要的话,甚至可以使用内核模块),而无需进行任何政治操作,我们还可以保证用户没有额外的内存使用量和完全的独立性。对于那些不太关心隐私的用户来说,这可能是一个不错的替代方案。一个初步的想法是某种通过观察系统自动推断的 Docker 容器(也许结合编写脚本来检测机器上正在运行的内容,以及使用语言模型来编写 Dockerfile)。

如果您对这些问题有任何好的想法,请发送电子邮件至 [email protected]。此外,如果您想从事此类工作,我们正在招聘

Anysphere制作
SOC 2 认证