我日常会同时开好几个 Claude Code / Codex 会话,每个会话在自己的 git worktree 里干活。互不干扰、随时可以丢弃,这本来是个好习惯。

直到这台 MacBook(可用空间 926 GB)在三周里满了四次。

四次满盘#

日期清理前可用清理后可用释放
9 月 7 日16 GB540 GB约 524 GB
9 月 25 日2.6 GB247 GB约 245 GB
9 月 30 日2.4 GB108 GB约 106 GB
10 月 1 日14 GB171 GB约 157 GB

四次清理都是我让 Claude Code 自己做的。每次清出来的空间,绝大部分是同一种东西:Rust 的 target/ 编译目录。第一次清理时,46 个 target/ 加起来有 467 GB,占了整块盘的一半。

为什么会这样#

问题出在 Rust 编译产物和并行 worktree 的组合上:

  1. 每个 worktree 各有一份 target/。 我的 Rust workspace 中等规模,debug 编译一次就是 10–40 GB。十个 worktree 就是十份。
  2. 会话结束后,target/ 没人删。 会话归档了、代码合并了,但目录还在,几十 GB 就一直躺着。
  3. agent 不会主动清理。 它的目标是让测试通过,不是替我省磁盘。

第一次“根治”,反而更糟#

第二轮清理后,我让 Claude Code 给其中一个仓库配了一个所有 worktree 共享的编译目录(在 .cargo/config.toml 里设 build.target-dir)。十份变一份,理论上能省下大头。

5 天后盘又满了,还多出一个陌生目录:~/.cache/cargo-target-claude/,里面一个子目录就有 156 GB。

翻会话记录才发现:没有任何规则设置过这个目录,是 agent 自己起的名字。

原因是共享编译目录有个副作用:cargo 会给编译目录加锁,多个 agent 同时编译时,后来的要排队等。agent 不想等,就各自把 CARGO_TARGET_DIR 指到一个新目录,绕开了锁,用完也没删。

磁盘 100% 时的 df 输出,以及 agent 自建的 156 GB 编译目录
9 月 30 日磁盘只剩 2.4 GB;次日发现 agent 自建的编译目录里,一个子目录就有 156 GB。根据当时会话记录里的真实输出排版,路径与仓库名已脱敏。

给 agent 加了约束,它会想办法绕过去。如果绕过去的那条路没人兜底,问题只是换了个地方出现。

清理时踩过的四个坑#

这些坑都是 Claude Code 在清理过程中踩的。都及时发现、恢复了,但每一个都值得变成规则。

坑 1:按名字删 dist/,删掉了签入仓库的文件#

它按 -name dist 批量删前端构建产物,命中了一个签入 git 的 vendored 依赖里的 dist/,波及 22 个 worktree、约 2300 个文件。好在这些文件都在 git 里,git checkout 就能还原。

更值得警惕的是:上一轮它用的是同一条规则,只抽查了 3 个仓库就判定“安全”。

规则:删任何“构建产物”之前,先用 git ls-files -- <目录> 确认里面没有被追踪的文件。目录叫 dist、build,不代表它就是产物。

误删后逐个仓库还原 tracked 文件的输出
只还原被删的 tracked 文件,不碰其它未提交改动;最后全量复核,没有仓库还留着删除记录。根据当时会话记录里的真实输出排版,路径与仓库名已脱敏。

坑 2:删掉了正在运行的开发服务器的依赖#

它特意写了跳过规则,要保留一个正在运行的 vite 开发服务器的 node_modules,但 shell 的模式匹配没生效,还是删了。服务器立刻开始返回 500,npm ci 重装后才恢复。

规则:删除前用 lsof +D <目录> 看有没有进程打开里面的文件,不要靠自己写的路径匹配去猜。

坑 3:检查和删除之间的竞态#

确认“没有 cargo 在跑”之后,它开始删那个 156 GB 的目录。删到一半,另一个会话恰好开始编译,往同一个目录里写文件,rm 报了 “Directory not empty”。另一个会话的那次编译只能从头全量重来。

规则:检查和删除之间总有时间窗口。删除要能容忍失败,删完要复核,不能假设一定成功。

坑 4:rm -rf "$DIR/$SUB"#

它写了一句拼接变量的删除命令,被 Claude Code 内置的安全检查拦下来了。道理很简单:变量一旦为空,这句就变成 rm -rf /。

规则:删除时用字面绝对路径,或者写成 "${DIR:?}/${SUB:?}",变量为空就直接报错退出。

Claude Code 内置安全检查拦下拼接变量的 rm -rf
Claude Code 的内置安全检查拦下了这句命令,并给出了更安全的写法。根据当时会话记录里的真实输出排版,路径与仓库名已脱敏。

回收方案:谁创建,谁回收#

清理只能治标。要根治,得回答两个问题:

  1. 编译目录建在哪里? 要建在能确定“主人”的地方。
  2. 谁负责删? 要有一个机制在会话结束时自动删,而不是靠人或 agent 记得。

我的方案分三层。

第一层:会话开始时,分配一个专属目录#

Claude Code 的 SessionStart hook 可以通过 CLAUDE_ENV_FILE 给会话注入环境变量:

export CARGO_TARGET_DIR="$HOME/.cache/cargo-target-claude/<session_id>"

这样每个会话独占一个目录,不用抢锁,agent 也就没理由再自己另起一个;目录名就是会话 ID,主人明确,回收时不用猜。

要注意的是,只对允许的仓库启用。我有些仓库的打包脚本写死了 target/release/... 路径,全局改 CARGO_TARGET_DIR 会让打包找不到产物。

第二层:会话结束时,回收自己的目录#

SessionEnd hook 在会话结束时触发,拿到 session_id,删掉对应的编译目录;如果会话跑在 .claude/worktrees/ 下的某个 worktree 里,顺带回收这个 worktree 里的 target/。

两个细节:

  • hook 有超时限制,删上百 GB 可能要几分钟。所以 hook 只负责启动一个脱离的后台进程,自己立刻返回。
  • hook 出错不能卡住会话,所有异常只写日志。

第三层:定时兜底#

Claude Code 没有“归档前”这个事件,SessionEnd 也不保证所有情况下都会触发(比如崩溃或强制退出)。所以还要一个定时任务,定期扫描超过 24 小时没动过的编译目录。

删除前的检查清单#

不管哪一层触发,每次删除前都过一遍:

  • 路径落在白名单根目录下,并且不是根目录本身
  • 确实是 cargo 编译目录(有 CACHEDIR.TAG,或有 debug/、release/)
  • git ls-files 为空,没有被追踪的文件
  • 最近 N 小时没有写入(定时兜底时)
  • lsof +D 没有进程打开里面的文件
  • 没有 cargo / rustc 进程在命令行里引用这个路径
  • 会话 ID 符合严格格式,防止 ../../ 这类路径穿越

目前进展#

回收脚本已经写好,按上面的清单做了检查。只读的预演结果符合预期:唯一的候选目录正被另一个会话使用,因为最近有写入而被跳过。

删除逻辑另外在一个隔离的临时目录里测过:闲置的编译目录会被删掉;最近写入过的、有进程占用的、被 git 追踪的、在配置根目录之外的,以及根目录本身,都会被跳过;伪造的 ../ 会话 ID 也会被拒绝。

回收脚本只读预演的日志
预演只写日志、不删除。唯一的候选目录刚被另一个会话写入过,所以被跳过。根据当时会话记录里的真实输出排版,路径与仓库名已脱敏。

在我自己的机器上,hook 和定时任务还没有启用。它们会改变之后每个会话的运行环境,Claude Code 的自动模式把这类改动判定为“修改 agent 自身环境”,要我本人逐项批准。我觉得这个拦截是对的。所以这篇写的是方案和踩坑记录,还不能证明它已经解决了问题;跑一段时间后再补实际效果。

让你的 Agent 也用上#

我把这套检查和回收方法整理成了一份公开 Skill:交给 Agent 回收编译产物。

把下面这段话复制给你的 Claude Code、Codex 或其他编程 Agent:

请先阅读 https://owenshen.top/skills/agent-build-gc/SKILL.md,按其中步骤帮我检查本机 AI 编程会话留下的 Rust 编译产物。先只做第 1 步的只读检查并给我报告;下载脚本、删除文件或修改 agent 配置之前,都要先说明会改什么并等我同意。

它会先只读检查,告诉你编译产物占了多少、在哪里。之后每一步(下载脚本、预演、真正删除、安装会话结束自动回收的 hook)都会先征求你的同意。这份 Skill 目前是实验性的,原因见上一节。

带走的四条经验#

  1. AI 编程助手默认磁盘是无限的。 并行会话开得越多,问题越严重。
  2. “共享”不一定省空间。 共享带来锁,锁会把 agent 推去绕路,绕出来的产物比共享之前更难追踪。
  3. 谁创建,谁回收。 让资源的生命周期跟会话绑在一起,比事后大扫除可靠。
  4. 批量删除要基于事实,不要基于假设。 用 git ls-files 判断,而不是看目录名;用 lsof 判断,而不是靠路径匹配;删完要复核。