作者的 runner 在新分支无 upstream 的情况下误判「有未推送工作」,导致提交后删除了唯一指向这些提交的引用链,两个 bug 叠加造成。
有一天,我的夜间报告在两个项目中各报了六次同样的内容:
我去查找这些提交记录,却发现根本没有任何提交。子进程在运行的第一秒就夭折了,而 harness 的二进制文件在前一天被移出了 PATH,runner 则对从未真正启动过的会话报告了 push 失败。第二天晚上,子进程正常运行了,在四个项目中有真实的提交,runner 却把所有提交都删了。同样的报告内容。
这个夜间任务刚新增了 merge-request 流程。每个子进程在临时 worktree 中基于每夜的分支进行工作;提交后,runner 会 push 分支并发起一个 merge request 供我第二天早上处理。这句话下面叠加了两个缺陷,第二个才值得专门写一篇文章来讲。
child, in a throwaway worktree runner
commits on the night branch
│
▼
did it commit? compare with upstream
✗ fresh branch, no upstream: every
child reads as "has unpushed work"
push, from the worktree
✗ push URL disabled there, by design
report: push failed, manual push needed
cleanup: remove worktree, delete branch
✗ the only ref to the commits is gone
第一个问题是判断"子进程是否提交了任何内容"。它拿分支和它的 upstream 做比较。新建的分支没有 upstream,查找失败后回退值被读作"有待 push 的工作",从那之后,无论子进程是正常工作还是早已死掉,在 runner 看来都一模一样。这是对事实的代理指标,而非事实本身。
第二个问题是 push 本身。我的 worktree 设计上就禁用了 push URL,这是保障无人值守 Agent 永远无法 push 的轨道,所以 push 恰恰从那个不能 push 的地方执行了。每次尝试都失败。报告说需要手动 push。然后清理步骤——它对以上一切毫不知情——移除了 worktree 并删除了分支,也就是那些提交唯一指向的引用。runner 摧毁了它刚刚让我去拯救的东西,而早晨的报告——它把 push 失败归类为我喝完咖啡后手动处理的事项——完全没有透露没有任何内容值得去 push 了。
拯救这些提交的是 git 保留无引用对象直到垃圾回收的习惯。恢复只需要三条命令,其中注释是那条重要的:
$ git fsck --no-reflogs --lost-found | grep commit
dangling commit 3f9c2a1d7e...
dangling commit 8b07d4c5a2...
$ git log -1 --oneline 3f9c2a1
3f9c2a1 drain: align the retry helper with the new timeout API
$ git branch rescue/2026-08-21 3f9c2a1 # a ref, nothing else
# do NOT gc, prune or worktree-prune first: unreachable is what gc
# deletes
当晚用这种方法找回了四个仓库。第五个有两个漏网的孤儿,在写这篇文章时手动找到的;其中一个已经存在五天了,而更晚的一条 merge 信息描述它已经在 main 分支上了。
修复需要三处改动,每一处都是同一条规则的版本:以可验证存在的东西作为判断依据,而不是以消息说的内容为依据。
-# did the child commit? a proxy: compare the branch with its upstream
-n=$(git rev-list --count "$b@{u}..$b" 2>/dev/null || echo 1)
+# did the child commit? the fact: HEAD moved while the child ran
+pre=$(git rev-parse HEAD)
+run_child
+post=$(git rev-parse HEAD)
+[ "$post" != "$pre" ] && committed=1
-# cleanup: always
-git worktree remove "$wt" && git branch -D "$b"
+# cleanup: only when the remote verifiably holds every commit
+n=$(git rev-list --count "$b@{u}..$b" 2>/dev/null || echo 1)
+[ "$n" -eq 0 ] && git worktree remove "$wt" && git branch -D "$b"
结果以子进程运行期间 HEAD 是否移动来衡量。在三十秒内死亡且没有任何提交的子进程,报告内容完全一致,会在报告中引用其自身输出的最后两行,当晚不会再获得新的运行轮次;真正的会话即使什么都没找到也需要耗时数分钟。push 从父级 checkout 执行,那里是允许 push 的。只有在远程缺少的提交数为零时——或者提交从未存在过——才会删除分支。"Push failed" 现在表示提交存在但远程没有,这才是它一直以来应该表达的唯一含义。
这个教训可以泛化到 git 之外。每个夜晚都会产生一份报告,而我一直把报告当作状态本身。它只是同一套程序对状态的一种声明,而那套程序本身就会犯错。
从外部看起来相同的两种失败——从未启动和启动后失败——需要不同的表述,因为操作员对每种情况的处理方式不同。我已经为 Agent 写下了这条规则:重复之前重新验证,从主要来源做断言。它对 shell 脚本同样适用,只是脚本更不可能意识到这个警告。