get-shit-done:修复 locked worktree 清理死锁与新增 worktree.reap-orphans 孤儿回收机制(PR 3707)
本篇基于 changeset 3707-worktree-orphan-reap.md 展开,解析 get-shit-done 中 worktree 清理链路的两个真实缺陷:executeWorktreeWaveCleanupPlan 在遇到 locked worktree 时被 Git 拒绝移除,导致每次 merge 后的 post-merge 清理全部卡死;以及此前崩溃退出的 agent 会话遗留的孤儿 worktree 无人回收。读完后你将理解修复背后的 unlock-then-retry 策略、gsd-sdk query worktree.reap-orphans 命令的 fail-closed 判定条件(pid 已死 + 分支已合并 + 锁文件 mtime 超过 5 分钟),以及它如何被接入 quick.md 与 execute-phase.md 的启动流程。
问题背景:locked worktree 让 git worktree remove --force 失效
get-shit-done 的 executor 会并行派发到隔离的 git worktree 中工作。一个 wave(同批并行计划)执行完毕后,executeWorktreeWaveCleanupPlan 负责把每个 executor worktree 合并回主分支并移除。修复前的收尾步骤是单条命令:
git worktree remove <path> --force
问题在于:Claude Code 的 agent 运行时会向 .git/worktrees/<id>/locked 写入锁文件。只要锁还在,Git 就会拒绝对该 worktree 执行 worktree remove——即使带了 --force 也不例外。于是只要运行期间有锁文件残留,整个 post-merge 清理流程(merge 之后的 remove 与 branch -D)就被阻塞,后续条目全部进入 pending,wave 清理以 cleanup_blocked 收场。
修复方案:unlock 后重试 remove
修复落在 worktree-safety.cjs 的 executeWorktreeWaveCleanupPlan 中:首次 remove 失败时,先尝试 git worktree unlock(忽略失败——可能本就没有锁),然后重试 remove,见 worktree-safety.cjs#L478-L485:
let remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
if (!gitResultOk(remove)) {
// Locked worktrees require unlock before remove (or --force --force).
// Attempt: git worktree unlock <path> (ignore failure — already unlocked is ok)
// then retry git worktree remove --force. (#3707)
execGit(['worktree', 'unlock', entry.worktree_path], { cwd: plan.repoRoot });
remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
}
需要注意 unlock 的失败被刻意忽略:若 worktree 本就未锁定,unlock 会报错,但这不影响后续 remove 的成功判定——只有重试后仍然失败才会把该条目标记为 blocked(reason 为 worktree_remove_failed),并把剩余条目推入 pending。
理解完整的 wave 清理安全链
这条重试逻辑处于一个层层设防的清理管道末端,每个 entry 都要先通过以下检查才会执行 merge 与移除(实现见 worktree-safety.cjs#L405-L507):
| 检查步骤 | 命令 | 失败时的 blocked reason |
|---|---|---|
| 分支一致性 | git -C <path> rev-parse --abbrev-ref HEAD 与 manifest 声明的 branch 比对 |
branch_mismatch |
| base 一致性 | git merge-base HEAD <branch> 与 expected_base 比对 |
base_mismatch |
| 删除检测 | git diff --diff-filter=D --name-only HEAD...<branch> 为空 |
deletion_check_failed / branch_contains_deletions |
| 脏检查 | git -C <path> status --porcelain --untracked-files=all 为空 |
worktree_dirty |
| 合并 | git merge <branch> --no-ff --no-edit |
merge_failed |
| 移除 | worktree remove --force(失败则 unlock 后重试) |
worktree_remove_failed |
| 删分支 | git branch -D <branch> |
branch_delete_failed(仅 warning,不阻塞) |
任一硬性检查失败即 break:当前条目标记 blocked,其后所有条目进入 pending,结果 JSON 的 reason 为 cleanup_blocked。全部通过则状态为 merged_removed。manifest 中的分支名还必须匹配 ^worktree-agent-[A-Za-z0-9._/-]+$ 白名单正则(worktree-safety.cjs#L313-L328),防止误清理非 executor 创建的 worktree。
该 CLI 入口由 gsd-tools.cjs 的 worktree cleanup-wave --manifest <path> 子命令暴露,输出结构化 JSON(含 plan 摘要与逐条目结果)。
新增命令:worktree.reap-orphans 回收孤儿 worktree
上面的修复只解决"锁还活着时清理被卡住"的问题。另一类更隐蔽的缺陷是:agent 会话崩溃后,锁文件与 worktree 元数据被永久遗留,堆积在 .git/worktrees/ 下。PR #3707 因此新增 worktree.reap-orphans 命令,在会话启动时清扫孤儿 worktree:
gsd-sdk query worktree.reap-orphans
核心实现是 worktree-safety.cjs 中的 reapOrphanWorktrees(repoRoot)。它扫描 .git/worktrees/ 管理目录下每个带 locked 文件的条目,并施加一组全部满足才回收的条件(源码注释明确标注 "Fail-closed — skip on any doubt"):
- 存在锁文件:
.git/worktrees/<id>/locked必须存在,否则跳过(不是本命令的职责); - 锁文件 mtime 超过 5 分钟:
REAP_MTIME_GUARD_MS = 5 * 60 * 1000(worktree-safety.cjs#L580),作为 PID 复用/竞态的保险丝。太新的锁直接跳过,reason 为lock_too_fresh; - 锁属主进程已死:从锁内容正则提取数字 PID,通过
process.kill(pid, 0)探测存活(worktree-safety.cjs#L810-L825)。这里有两处关键的 fail-closed 设计:- 锁内容不是纯数字 PID(例如 Claude Code 实际的文本格式
Locked by claude-code agent-xxxx)时,视为存活,reasonlock_owner_unknown——无法确认属主已死就不能回收; - 探测抛
EPERM(进程存在但无信号权限,Windows 上跨用户进程常见)时同样视为存活。任何"无法确定已死"的歧义一律不回收;
- 锁内容不是纯数字 PID(例如 Claude Code 实际的文本格式
- 分支 tip 已合并进默认分支:读取管理目录下的
HEAD文件(支持ref: refs/heads/<branch>与裸 SHA 两种形态),用git merge-base --is-ancestor <branchTip> <mainTip>验证。默认分支的确定本身也是 fail-closed 的(worktree-safety.cjs#L601-L662):优先refs/remotes/origin/HEAD这个权威集成分支;仅当远端完全不存在时(如本地测试 fixture)才按init.defaultBranch→ HEAD symref →main→master的候选顺序回退;远端存在但origin/HEAD未设置则视为歧义直接放弃整轮清扫。源码注释特别说明有意排除当前HEAD:detached 或停在 feature 分支上时使用 HEAD 会让所有分支看起来"已合并",造成误回收。未合并的条目 reason 为branch_not_merged。
四个条件全部通过后执行回收动作:worktree unlock → worktree remove --force → 最后无条件执行一次 worktree prune 清理磁盘上已不存在的元数据残留。回收成功的条目 reason 为 pid_dead_and_merged。
一个容易踩坑的细节:symlink 路径归一化
代码中有一处针对 macOS 的专门处理(worktree-safety.cjs#L664-L686):macOS 上 os.tmpdir() 可能是 /var/folders/... 符号链接,而 Git 在 gitdir 文件中记录的是 /private/var/folders/... 真实路径。直接拿 gitdir 里的路径去执行 worktree unlock/remove 会找不到目标。因此实现先用 git worktree list --porcelain 建立"真实路径 → git 登记路径"的映射(fs.realpathSync.native 归一化),回收时统一使用 git 认知的路径。Windows 上 porcelain 输出可能是 CRLF,也会先归一化为 LF 再按 \n\n 分块解析。
输出形态与运行特性
CLI 包装 cmdWorktreeReapOrphans(worktree-safety.cjs#L839-L854)有两个值得注意的行为:
- 整个清扫是非致命的:即使内部抛错,也只向 stderr 写一行
[gsd] worktree.reap-orphans failed: ...并保持退出码 0,不阻断宿主 workflow; - 有被跳过的孤儿时输出提示
N orphan(s) skipped (run with DEBUG=1 for details); - stdout 为 JSON:
{ ok: true, reaped: <数量>, entries: [{ path, status: 'reaped' | 'skipped', reason }] },可被上层程序直接解析。
所有外部依赖(execGit、isPidAlive、readDirSafe、readFileSafe、mtimeSafe、reapMtimeGuardMs)均可通过 deps 参数注入替换,默认阈值 5 分钟也可覆写,这为单元测试提供了完整的 seam。
启动接线:quick 与 execute-phase 工作流
changeset 声明该命令被接入 quick.md 与 execute-phase.md 的启动阶段,两条 workflow 中的实际接线如下。
quick.md 在解析完运行配置后、派生任何 executor 之前执行启动清扫:
USE_WORKTREES=$(gsd-sdk query config-get workflow.use_worktrees 2>/dev/null || echo "true")
if [ "$USE_WORKTREES" != "false" ]; then
gsd-sdk query worktree.reap-orphans 2>/dev/null || true
fi
execute-phase.md 的接线更为紧凑,且紧跟在 fail-closed 的运行时检查(Codex 不支持 worktree 隔离时直接 exit 1)之后:
# Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707).
[ "$USE_WORKTREES" != "false" ] && gsd-sdk query worktree.reap-orphans 2>/dev/null || true
两处接线遵循同一契约:仅在 workflow.use_worktrees 不为 "false" 时执行(配置关闭 worktree 隔离则无孤儿可言);2>/dev/null || true 保证清扫失败永不阻断主流程——这与 cmdWorktreeReapOrphans 内部的 exit-zero 设计呼应,构成双重保险。选择"启动时清扫"而非"清理时清扫"的原因是:孤儿锁的属主进程早已不存在,事后补救只能等下一次会话,而在派生新 executor 前清扫可避免与陈旧 worktree 的路径/分支冲突。
SDK 查询层与路由
gsd-sdk query 到具体实现的调用链为:QueryHandler → spawnSync 子进程 → gsd-tools.cjs 路由 → worktree-safety 模块。
- 查询层:sdk/src/query/worktree.ts#L82-L84 定义
worktreeReapOrphanshandler,通过worktreeSpawn('reap-orphans', [], projectDir)以当前 Node 解释器拉起gsd-tools.cjs worktree reap-orphans,子进程带 30 秒超时、1MB buffer 上限,并注入GIT_TERMINAL_PROMPT=0与GCM_INTERACTIVE=never环境变量防止 git 在无人值守场景下挂起等待凭据输入;stdout 按 JSON 解析后直接作为 handler 返回的data。 - 路由层:gsd-tools.cjs#L1205-L1215 中
case 'worktree'分发cleanup-wave与reap-orphans两个子命令,未知子命令报SDK_UNKNOWN_COMMAND。
测试验证
tests/bug-3707-locked-worktree-cleanup.test.cjs 是针对 #3707 的真实文件系统回归测试(非 mock 测试),文件头部注释明确了三个固定断言点:
// Real-filesystem tests for the two failure modes pinned in #3707:
// 1. executeWorktreeWaveCleanupPlan must unlock-then-retry when a worktree is locked.
// 2. reapOrphanWorktrees must reap dead-pid+merged entries and skip live / unmerged / fresh-mtime entries.
// 3. quick.md and execute-phase.md must wire gsd-sdk query worktree.reap-orphans at startup.
测试实现上有两个可借鉴的细节:
deadPid()通过实际派生一个立即退出的node -e ""子进程并等待其结束来获取"保证已死的 PID",规避了硬编码大 PID 在pid_max差异下的竞态(bug-3707-locked-worktree-cleanup.test.cjs#L30-L40);worktreeMeta()通过解析git worktree list --porcelain与.git/worktrees/<name>/gitdir文件反查指定 worktree 的管理目录,用于精确写入/检查locked文件与 mtime。
此外 tests/worktree-safety.test.cjs 通过 deps.execGit 注入 mock 对同一模块做纯逻辑覆盖,两者互补。
小结:fail-closed 设计在清理类命令中的落地范式
#3707 的价值不仅在于两个具体修复,更在于它示范了自动化清理类 git 操作的安全设计原则,全部可从源码验证:
- 不确定即不动:锁属主无法确认死亡(非数字锁文本、EPERM)、默认分支无法权威确定(远端存在但
origin/HEAD未设置)、分支 tip 无法解析——全部跳过并给出结构化 reason,而非默认可清理; - 时间窗保险丝:5 分钟 mtime 阈值同时防御 PID 复用与会话刚写入锁的竞态,且通过
deps.reapMtimeGuardMs可测可控; - 非致命集成:作为启动钩子时,命令自身保证 exit-zero、workflow 侧再用
|| true兜底,清扫失败永远不升级为流程失败; - 路径归一化:面对 macOS symlink、Windows CRLF、gitdir 相对路径三类路径陷阱,统一以"git 自己登记的路径"为准执行 unlock/remove。
如果你在本地仓库中手动排查此类孤儿 worktree,可以直接执行 git worktree list --porcelain 查看 locked 标记,检查 .git/worktrees/<name>/locked 文件内容,确认属主进程已死且分支已合并后,手动执行 git worktree unlock <path> && git worktree remove <path> --force——这正是 reapOrphanWorktrees 自动化完成的全部动作。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00