首页
/ get-shit-done:修复 locked worktree 清理死锁与新增 worktree.reap-orphans 孤儿回收机制(PR 3707)

get-shit-done:修复 locked worktree 清理死锁与新增 worktree.reap-orphans 孤儿回收机制(PR 3707)

2026-09-04 20:01:43作者:宣利权Counsellor

本篇基于 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.mdexecute-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.cjsexecuteWorktreeWaveCleanupPlan 中:首次 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 的 reasoncleanup_blocked。全部通过则状态为 merged_removed。manifest 中的分支名还必须匹配 ^worktree-agent-[A-Za-z0-9._/-]+$ 白名单正则(worktree-safety.cjs#L313-L328),防止误清理非 executor 创建的 worktree。

该 CLI 入口由 gsd-tools.cjsworktree 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"):

  1. 存在锁文件.git/worktrees/<id>/locked 必须存在,否则跳过(不是本命令的职责);
  2. 锁文件 mtime 超过 5 分钟REAP_MTIME_GUARD_MS = 5 * 60 * 1000worktree-safety.cjs#L580),作为 PID 复用/竞态的保险丝。太新的锁直接跳过,reason 为 lock_too_fresh
  3. 锁属主进程已死:从锁内容正则提取数字 PID,通过 process.kill(pid, 0) 探测存活(worktree-safety.cjs#L810-L825)。这里有两处关键的 fail-closed 设计:
    • 锁内容不是纯数字 PID(例如 Claude Code 实际的文本格式 Locked by claude-code agent-xxxx)时,视为存活,reason lock_owner_unknown——无法确认属主已死就不能回收;
    • 探测抛 EPERM(进程存在但无信号权限,Windows 上跨用户进程常见)时同样视为存活。任何"无法确定已死"的歧义一律不回收;
  4. 分支 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 → mainmaster 的候选顺序回退;远端存在但 origin/HEAD 未设置则视为歧义直接放弃整轮清扫。源码注释特别说明有意排除当前 HEAD:detached 或停在 feature 分支上时使用 HEAD 会让所有分支看起来"已合并",造成误回收。未合并的条目 reason 为 branch_not_merged

四个条件全部通过后执行回收动作:worktree unlockworktree 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 包装 cmdWorktreeReapOrphansworktree-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 }] },可被上层程序直接解析。

所有外部依赖(execGitisPidAlivereadDirSafereadFileSafemtimeSafereapMtimeGuardMs)均可通过 deps 参数注入替换,默认阈值 5 分钟也可覆写,这为单元测试提供了完整的 seam。

启动接线:quick 与 execute-phase 工作流

changeset 声明该命令被接入 quick.mdexecute-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 定义 worktreeReapOrphans handler,通过 worktreeSpawn('reap-orphans', [], projectDir) 以当前 Node 解释器拉起 gsd-tools.cjs worktree reap-orphans,子进程带 30 秒超时、1MB buffer 上限,并注入 GIT_TERMINAL_PROMPT=0GCM_INTERACTIVE=never 环境变量防止 git 在无人值守场景下挂起等待凭据输入;stdout 按 JSON 解析后直接作为 handler 返回的 data
  • 路由层:gsd-tools.cjs#L1205-L1215case 'worktree' 分发 cleanup-wavereap-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 自动化完成的全部动作。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
docsdocs
暂无描述
Markdown
889
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341