在Windows环境下并行运行多个编码Agent需要解决HOME路径、配置隔离、日志归属、失败清理四大问题,提供了具体方案。
有人在上一篇文章的评论区留了一句评论,骨架比文章本身还清晰:
并行 Agent 只有在工作区边界无聊且明确的情况下才实用。尤其在 Windows 上,与其关心启动技巧,不如多关注隔离、日志、文件所有权和失败后的清理。
说这话的是 Alex Shev,他是对的。四个 Agent 放在窗格网格里只是截图效果。他列出的这四个问题,才决定你一个月后还会不会继续用这套方案。
我从事 NestMux 的工作,所以这事跟我有利益关系。以下大部分内容就是普通的 Windows 和普通的 git,不管你用不用 NestMux 命令都一样跑。我会在描述我们所做的决策时明确说明,也会说明哪里还存在不足。
两个 Agent 用同一个 Windows 账户运行,会共享 ~/.claude、~/.codex、~/.gemini。配置共享,更重要的是认证会话也共享。如果你想两个 Claude 账户并行,就得用不同的主目录。
在 Windows 上,HOME 不是那个能把你带到目标位置的变量。它是 POSIX 规范,有些工具认它,但 Windows 本身不认。只设 HOME 的话,得到的是一个半吊子的重定向 Agent:CLI 把配置写到新位置,而 git 把 .gitconfig 写到旧位置,你直到两个窗格开始共享同一个 git 身份才会发现不对劲。每个进程实际需要的是:
HOME = <accountDir>
USERPROFILE= <accountDir>
HOMEDRIVE = C:
HOMEPATH = \path\to\accountDir
Gemini CLI 除此之外还需要 GEMINI_CLI_HOME 指向它自己的子目录。
如果你在写启动器(而不是用现成的)有一个坑:Node 的 os.homedir() 在 Windows 上并不能可靠地反映你 spawn 时注入的 USERPROFILE。如果你的应用读取 homedir() 来决定自己的存储位置,同时又为子进程重定向了 USERPROFILE,这两者就会不一致。我们最后给存储专门弄了一个 env 变量,又写了一个单独的函数来处理"新 shell 应该从哪里启动",因为把它们合并意味着每次新建终端都会跑到 Agent 配置目录里去。
没人想要完全隔离。CLAUDE.md、settings.json 和 skills 在每个窗格里应该是一样的。所以它们被链接回全局副本,而不是复制一份。
在 Windows 上这个链接比听起来更麻烦。mklink 做文件符号链接需要提权或者开开发者模式。 junctions 不需要,所以目录没问题。对于文件,备选方案是硬链接。
这个备选方案有个隐患。硬链接不是符号链接,所以 lstat().isSymbolicLink() 对它返回 false。如果你后来提供一个"将此账户从共享配置中断开"按钮,实现方式是"把符号链接替换成真实副本",硬链接的文件会被跳过,然后这个账户就继续悄悄编辑你的全局配置,而 UI 还在显示它已经分离了。要检测它们就得比较文件 id:
const a = lstatSync(src, { bigint: true })
const b = lstatSync(dest, { bigint: true })
const sameFile = a.dev === b.dev && a.ino === b.ino && a.ino !== 0n
bigint: true 很关键。在某些 Windows 配置下,普通的 ino 会返回 0,这就导致每一对文件看起来都像同一个文件。
这是产生技术支持问题的那个点,而且错误信息还指向了完全错误的方向。
建一个带 worktree 的仓库,然后在其中一个目录里放一个把真实工作目录作为当前目录的进程:
git -C C:\dev\repo worktree add C:\dev\feat -b feat
Start-Process cmd -WorkingDirectory C:\dev\feat -ArgumentList '/c','timeout /t 60'
git -C C:\dev\repo worktree remove C:\dev\feat --force
error: failed to delete 'C:/dev/feat': Permission denied
这跟权限一点关系都没有。Windows 不会删除某个进程的当前目录,而 git 把这个拒绝上报为权限错误。在 Linux 上同样的删除会成功,所以这个问题通常先作为 bug 报告到达 Windows 用户手里,而没人能复现。
有两件事我只有通过尝试写可靠的拆除逻辑才发现:
PowerShell 的 Set-Location 不会复现这个问题。PowerShell 的 location 是叠加在进程之上的 provider 概念。底层的工作目录停留在进程启动时的位置。所以 Start-Process powershell -Command "Set-Location C:\dev\feat; ..." 会让删除成功,你就会以为问题不是真实的。用 -WorkingDirectory,或者用 cmd,或者任何能设置真实 cwd 的方式。
杀 shell 不够。在上面的运行中我杀掉了 cmd.exe,删除仍然失败。持有者是 timeout.exe,一个继承了工作目录且活得比父进程更久的子进程。你需要的是进程树,不是一个进程。
对于任何管理 worktree 的东西的实际后果:在你碰文件系统之前,你得知道哪些窗格正坐在那个目录里。这意味着在 spawn 时记录每个窗格的 cwd,然后用路径前缀来杀进程(需要规范化,因为在 Windows 上同一个 worktree 会根据是谁写的路径而呈现为 C:\dev\feat 或 C:/dev\feat 两种形式)。
这是我没想到的部分。一次失败的 git worktree remove 不是空操作。续上面的失败:
> git -C C:\dev\repo worktree list
C:/dev/repo 39ca293 [master]
> dir C:\dev\feat
(empty)
> git -C C:\dev\repo worktree remove C:\dev\feat --force
fatal: 'C:\dev\feat' is not a working tree
> git -C C:\dev\repo worktree prune -v
(nothing)
Git 删除了 worktree 的内容,删除了 .git/worktrees 下的管理目录,并把条目从 git worktree list 里拿掉了。然后它遇到了被锁住的顶层目录就停住了。剩下的东西是一个空文件夹,git 不再认识它、不愿意删除它、也不认为它是悬空的。分支还在那里。prune 没有什么好清理的,因为元数据已经没了。
所以恢复是手动的,而且顺序很重要:
先把持有句柄的东西杀掉,进程树也要算进去。
自己动手删掉目录。
之后运行 git worktree prune,应对的是 git 没有走到清除自己元数据那一步的情况。
先 prune 的话,你可能在清理仍然引用着你即将删除的目录的元数据。
我们在应用里最后定下的规则,是用一次 bug 换来的:如果这两个步骤中任何一个失败了,就保留这个条目并报告失败。诱人的做法是丢弃你自己的记录然后声称已经删除了,毕竟用户要求它消失。然后下一次刷新读取 git worktree list 或者残留的目录,这个 worktree 又出现了,看起来还挺健康。现在它就完全没法通过 UI 移除了,因为移除它的代码路径假设的是它刚刚丢失的那个状态。一次部分删除报成成功,比一个错误信息更糟糕。
同一领域的两件小事:
在读取时做对账。人们会在你的应用外面删除 worktree,用 rm -rf 或者原生的 git。如果你的列表来自你自己的存储,条目就会变陈。每次列清单时对比 git worktree list,把 git 不再认识的东西标出来,删除目录已从磁盘消失的条目。
永远不要在 .git 里面创建 worktree。我们早期有一个版本把一些 worktree 放在了 .git/worktrees 下面,那是 git 自己的元数据文件夹。Git 会创建它,然后拒绝把它当作工作树,所以 git worktree remove 永远失败,唯一的出路是手动删除。如果你是用仓库路径加分支名来构建路径,先检查你即将落地在哪里。
大多数并行 Agent 方案在创建 worktree 后会运行一些东西:npm install、复制 .env、一个 build。这是一个失败会被吞掉的地方。
三件值得有的事,都不花哨:
每个命令设置超时。出错的命令没问题,你看得到。让你栽跟头的是那个什么都不打印也永远不退出的命令。等待 stdin 上的输入就会这样。每个命令十分钟,然后 kill 它。
拆除时取消。如果在有人移除 worktree 时设置还在跑,先取消它。不然你就是在删除一个正被活着的 npm install 写入的目录,这就又回到上一节的内容了。
清除敏感信息。设置日志会抓到 env dump 或 verbose install 中的 TOKEN=... 行。如果你保存这些日志,在进去的时候就清除它们,不要等出来再处理。
这是评论击中要害的地方,也是我展示得最少的地方。
NestMux 现有的是每个窗格一份 transcript,可以保存并导出为 markdown,以及每个 worktree 一份设置日志,上限 200 行并清除敏感信息。不存在的是任何统一的东西:跨窗格的一条时间线,带时间戳、退出码,以及每行属于哪个 worktree。
而这恰恰是评论描述的那个场景里你需要的 artifact。一次夜间运行失败了,四个 Agent 在工作,问题是哪一个碰到了什么、按什么顺序碰的。每窗格一份 transcript 让你只能从四个回滚记录里手动重建。
我没有计划发货日期。我把它写成差距而不是计划,因为诚实的状态是每窗格 transcript 容易,跨窗格且正确归属的日志难,尤其是当窗格来来去去的时候。
启动技巧确实是简单的那部分。所有昂贵的东西都在拆除里:谁握着句柄、一次失败的删除留下了什么状态、以及你自己的世界记录在那之后是否还和磁盘一致。在 Windows 上这是不同于 Linux 的另一套失败模式,而且错误信息更糟糕。
如果你在并行运行 Agent,而且你有一个能在失败运行中存活下来的拆除方案,或者一个真能回答"哪个 Agent 干的这事"的日志方案,我想看看。尤其如果它认为这事应该是个脚本而不是一个应用。