Flowness 工程进展:从需求到交付 · 阅读系列导读 →
Harness 模式 · 第 2 期
今年 5 月,有人在 Claude Code 的 GitHub 仓库里报了一个问题(#63023)。楼主派了几个子 Agent 在后台干活,每个都在自己的 git worktree(git 给同一个仓库开的另一个工作区,有自己的文件和分支)里改代码。电脑合上过了一夜,第二天回来,这几个 Agent 不见了,一条完成通知也没有。主 Agent 照常往下派下一批,直到有人手动去查远端仓库,才发现什么都没落地。楼主说,这些没了的 Agent “look indistinguishable from successfully-completed agents”:看上去和做完了的一模一样。
我们自己做的 Agent 系统里也出过这种事:起因不同,结果一样,当时能看到的那一路监控一直是绿的。6 月底的一个深夜,我们改了一项设置:从那一刻起,派出去的每个 Agent 都在自己的 worktree 里干活,做完再把改动合回主干。负责派活的循环(下文叫派活循环)每隔两三分钟就在事件记录(系统按时间记下每件事的那份记录)里写一条例行记录,相当于它的心跳。从改完设置到第二天我们叫停派活循环,一共 252 条,中间没有一次断过十分钟。
同一段时间里,改完设置以后派出去的任务,有 8 个报了成功完工。合回主干的:0 个。
那一夜并不是没人出声,8 次合回失败都记下了。**缺的是一个数:完工多久了,还没合回去。**没有一样东西算过它。
这篇讲这个数怎么算,以及算它的程序自己停了怎么办。模式叫“沉默不是证据”:心跳只证明程序还活着;“没有告警”也可能是根本没查;检查的程序停了,它的沉默和“一切正常”分不出来。要装的是四样:盯产出,不盯心跳;没发现,也记一条;让别的东西盯着巡检(定时跑的检查程序);告警交到有人接的地方。
文末附一套最小可跑的配置:两个由 cron 定时跑的 shell 脚本和一个告警出口,在 Claude Code 2.1.302 的后台 Agent 和 macOS 自带的 cron 上跑通过;测的时候周期和门槛缩到了分钟级,告警发到本机的一个收件端。它管得住什么、管不住什么,第七节有一张表。
一、“出了错总会报出来”,为什么不够
很多人对后台任务的默认想法是:出了错总会报出来。程序崩了有退出码,命令失败有日志,再接上告警,没告警就是没事。这个想法对崩溃成立:进程一挂,心跳就停,监控马上变红。
那一夜的毛病不在这里:8 条合回失败记进了一个只记录、不发通知的列表,那一夜这个列表进了 310 条;干活的 Agent 自己也写过问题记录,说任务没落到主干上。我们第二天回来追问,才把这两处拼起来。
信号不缺,散在几个地方。缺的是一个程序去问:报了完工的那几件,现在在主干里吗?
这件事难在它问的是“没发生”。失败会留下一条记录,崩溃会让心跳变红;一次本该发生的合并没有发生,什么记录都不会留下。只对“发生了什么”做反应的监控,看不见“该发生的没发生”。
| 常见做法 | 漏掉什么 | 这篇怎么补 |
|---|---|---|
| 看心跳、进程在不在 | 活着,没交活 | 盯产出 |
| 没告警就当没事 | 巡检没跑也没告警 | 没发现也记一条 |
| 装一个巡检 | 巡检停了没人知道 | 外面再盯着巡检 |
| 告警发出去就行 | 发出去没人接 | 交到有人接的地方 |
二、老方法,新补丁:死人开关要盯对信号
死人开关是个老办法,搬到 Agent 身上要改一处:它盯的那个信号,得是只有真在干活才发得出来的。
这个名字来自火车上那根松手就刹车的手柄;人晕倒时也可能压住它,所以后来又加了警惕装置,要司机每隔一阵动一下控制手柄。
心跳就是那个被压住的手柄。进程在、循环在转,它就一条接一条地来,活有没有交上来,它不管;那一夜派活循环的 252 条就是这样来的。对 Agent 来说,“动一下控制手柄”是改动真的合进了主干:只有活干完、交上来,这个信号才会出现。
另一半在 Prometheus 的告警里。kube-prometheus 那套默认告警规则里有一条叫 Watchdog,一直在响,用来证明整条告警管道是通的;接上 PagerDuty 一类服务以后,它不响了才通知人。这就是给告警管道装的死人开关:**要让沉默有意义,得有一样东西按时出声,它不出声,本身就是消息。**巡检也该这样,每一轮都出一声。
这个比喻有一处不成立:火车的警惕装置盯的是司机本人,我们的死人开关只盯巡检有没有按时留下记录;巡检在跑、却没想到要查某一样,它听不见。所以巡检查哪些项,也要有人定期回头看。
三、盯产出,不盯心跳
追问那天晚上,我们写了一个专查这件事的定时检查,下文叫它合回检查,代码开头写着一句:心跳不等于产出。它只问一个问题:有没有任务报了成功完工,过了一小时还没有合回主干?有,就报。
一小时是留给正常合并的时间,短于这个,活可能还在路上,报了就是误报。它当时每半小时扫一轮,一份活完工却没合回去,只要它在跑,最晚一个半小时会被查出来。当晚拿真实仓库跑了一遍,按它的判据查出 20 个超过一小时没合回的 worktree,那一夜的和更早就卡着的都在里面,那项设置 6 月中旬试开过一次。
查出来以后,它不自己动手:不替 Agent 合并,也不删 worktree,只写一条告警,交给修复那一头。7 月它这样走完过一整轮:告警、派 Agent 修、合并、关闭。不自己动手是有意的取舍:我们定过一条规矩,没合回去的工作永远不自动删除,确认内容已经在主干里了,才做有记录的关闭。代价是磁盘,追问那天,磁盘上的 worktree 一共占了 33 GB;可删错了,就找不回来。
那一夜,每个完工的任务只换来一次合并尝试和一条失败记录,循环接着转下一圈。所以盯产出的检查不能只在“刚完工”那一刻看一眼,得每一轮都把“报了完工的”和“主干里有的”整个比一遍,不管中间漏掉过哪件事。Kubernetes 的控制器就是这样设计的:只比应有的样子和现在的样子。
落到读者手里,这件事是一条 git 命令。Agent 在 worktree 里干活,每件活一个分支;git branch --no-merged main 列出还没合进主干的分支,再看每个分支最后一次提交离现在多久,超过一小时的报出来。附录的巡检就是这样写的。它拿最后一次提交的时间当完工的时间,Agent 还在干、一小时没有新提交,也会被报。你们要是用 squash 合并,已经合进去的分支也会被它当成没合,第八节说怎么办。
光看提交还不够。Agent 改到一半被停掉,改动留在 worktree 里没提交,分支上什么也看不出来。所以附录的巡检每轮还看一遍各个 worktree(你自己干活的主目录不看):有没提交的改动,改动的文件又超过一小时没再动过,也报出来。Agent 在想、在等一条长命令,一小时没碰文件,同样会被报,这是只报不判的代价。
有一种 Agent 两套都看不见:派出去以后一个文件都没改就没了。它没报完工,我们的合回检查对不上它;它不留提交和改动,附录这套也看不见。要抓它,得拿一份派活清单一件件对,附录没有这份清单。
10 月 17 日我们去看的时候,合回检查还在每半小时跑一轮,最近一轮报的是 0:没有哪个 worktree 里的活完工超过一小时还没合回去。
这个 0,是查过了真没有,还是这一轮根本没跑?
四、没发现,也记一条
只在有问题时才记一笔的系统里,这两种情况在记录上长得一模一样。
9 月写另一套巡检(不是第三节的合回检查)时,我们把这件事写进了代码开头,标题就叫“沉默不是证据”:
“没有告警”有两种截然不同的来历:扫了、什么都没有,和根本没扫。……一个停掉的探针,看起来和一个健康的系统完全相同,而且越安静越像健康。
办法很简单:每扫一轮,不管查出几条,都落一条“这一轮扫完了”,把这一轮查过的每一项都列进去。“上一轮扫过没有”就成了一个能对着记录问的问题。
那套巡检当时平常约 20 分钟落一条。9 月中旬到 10 月中旬,有 47 次隔了一小时以上,最长一次 12 小时 21 分,为什么隔了这么久,记录里没有写。要是只在查出新问题时才记一笔,这 12 个小时在记录上什么也看不出来。
附录的巡检照这个做,记得简单些:每轮在 .watch/log 里写一行,记下查出几个、是哪几个,一个没查出也写 late=0;git 命令出错就不写,留给下一样东西去发现。
那 47 次空档数得出来,是因为记录在。可数它,还得有东西去数。
五、让别的东西盯着巡检
检查的程序最省事的放法,是挂进它要盯的那个程序里,每转一圈顺手查一下。合回检查我们没这样放,理由写在它的代码开头:它要是和派活循环同生共死,循环一停或一崩,它也跟着没了,正是那一夜的同一种失明。所以它当时是一个独立的定时任务,到点被叫起来,扫一轮,退出。
放在外面,还得有东西盯着它。合回检查当时外面没有东西盯它的运行记录。它每轮都落一条记录,查出 0 个也落;可 9 月有 13 天,这条记录一条也没有,同期系统照常在跑。这 13 天,是我们 10 月为写这篇翻记录时才数出来的;原因,还有那段时间有没有漏掉活,我们都没有查清。
附录的死人开关是这一层的最小版。它不看你的分支,只看巡检每轮写的那一行:那一行超过两个巡检周期没更新,就叫人。它也只做这一件事:不判巡检死了,也不替巡检重启,只叫一个人去看一眼。
这一行跟心跳不一样:巡检只在一轮查完以后才写它,git 出错就不写。对巡检来说,查完一轮就是它交的活,盯这一行,盯的就是它的产出。死人开关能这样盯,靠的是上一节那条规矩,巡检每轮都写,查出 0 个也写;只在有问题时才说话的巡检,装不了死人开关。
最小版里,死人开关和巡检写在同一份 crontab 里:只停掉巡检那一行,它会响;cron 整个停了,两个一起停,谁也不响。它也只管巡检停没停,巡检在跑却查错了东西,它不会响。
巡检在跑,死人开关也在,告警发出去了,事情就交出去了吗?
六、告警交到有人接的地方
合回检查 7 月 5 日上线,到 9 月初,记了 153 条“完工了没合回去”的告警,有一个 worktree 被告警了 19 次。告警响了很多遍,修的那一头没跟上。这是我们选“只报不修”付的代价:告警把事交出去就算完,接没接住,要另找东西去盯。
附录里,告警交给谁,由 crontab 里设的 ALERT_CMD 决定。ALERT_CMD 要指向一个有人值班、有人负责处理的地方;只写进本机的记录,或者发进一个没人看的群,就等于没交出去。
七、照着搭
附录四份放进 Agent 干活的那个仓库,.gitignore 再加一行:
你的仓库/
├── scripts/
│ ├── watch-output.sh 巡检:没合回、没提交
│ ├── deadman.sh 死人开关:盯巡检的记录
│ └── alert.sh 告警出口
├── crontab.txt 两行:巡检、死人开关
└── .gitignore 加一行 .watch/
巡检每 30 分钟跑一次,死人开关每 10 分钟跑一次,告警都经 alert.sh 交给第六节说的 ALERT_CMD,比如一条往群机器人发消息的 curl。巡检、死人开关和告警出口合起来:
| 要管住的事 | 附录这套 |
|---|---|
| 提交了、一小时没合回 | 报 |
| 还在干、一小时没提交 | 会误报 |
| 改了没提交、一小时没动 | 报 |
| 改了没提交、还在动 | 不报 |
| 派出去、一个文件都没改就没了 | 看不见 |
| 子 Agent 不开 worktree、改在主目录 | 看不见 |
| squash 合并过的分支 | 会误报 |
| 巡检停了 | 死人开关报 |
| cron 整个停了 | 都不报 |
| 巡检在跑、查错了东西 | 不报 |
| 合进本地 main、没推到远端 | 看不见 |
| 告警通道自己断了 | 看不见 |
搭完要故意犯一次:留一个提交了、不合回的分支,看巡检报不报;停掉巡检,看死人开关叫不叫你。做法在附录末尾,最慢的一步要等一个多小时,回到电脑前再做。我们在三个新仓库里把周期缩到分钟级测过,三次都报了、响了。从 webhook 到你手机上那一段,我们替你测不了:故意犯一次时收没收到,测的就是它。
八、代价与不适用
squash 合并或 rebase 合并以后,分支里的改动进了主干,提交却不在主干的历史上,巡检会一直报它。合完先移除它的 worktree,再把本地分支删掉,这个误报就没了;在远端合 PR 的,还要把本地 main 拉到最新,巡检比的是本地 main。故意留着的实验分支也会被报:要么删掉,要么打成标签再删分支,巡检只看分支。
告警会重复。附录的巡检每一轮都报,直到有人处理;重复是有意的,报一次就不再提,比这更糟。我们的合回检查没有每轮都报:修复还在进行就不重报,过了两小时还没合回才再报,那个被告警 19 次的 worktree,就是这样一次次攒出来的。
你要是在笔记本上跑这一套,合盖睡着时 cron 不跑,巡检和死人开关一起停。合上一夜再打开,死人开关多半比巡检先跑到,在巡检跑上之前每 10 分钟报一次“巡检已经多少秒没有新记录”,一般一两条。它说的是真的,合盖那段没人盯;不想每天早上收这几条,就把这套放在不睡的机器上,或者接受开盖时的这几条。
不需要这一套的:你一直在旁边看着一个 Agent,它改完你马上合并;一天就跑几件活,每件都是你亲手合的。装上以后,巡检连着几周都报 0,可以把周期放长到几个小时,LIMIT 跟着改成两个周期多一点,但**别拆:它报 0 的那几周,也是它在证明自己还在。**死人开关跟着巡检走,巡检在,它就得在。
回到“一模一样”
回到开头那句:那几个 Agent 看上去和做完了的一模一样。分不出来,是因为该发生的事没发生,不会留下任何记录:心跳照常,告警安静,什么都像是好的。
拿我们那一夜走一遍。改完设置约 4 小时,第一个任务报了完工、合不回去;装着附录这一套,巡检最晚再过一个半小时就会报出它,告警发到有人值班的地方,不用等到第二天我们回来追问。巡检自己要是停了,它那一行记录不再更新,死人开关会叫人去看。
楼主那几个 Agent 是合着电脑过的夜,照第八节说的,那一夜这一套也跟着停;开盖以后巡检跑的第一轮,就会把它们报出来。楼主说那些 worktree 停在改了一半的状态,有的可能在自己的分支上提交了、没推上去:改了没提交的,巡检报的是一小时没动的 worktree;提交了的,报的是没合回的分支。
今天能做的第一步:在 Agent 干活的仓库里跑一次下面这条命令,它列出还没合进 main 的分支,和每个分支最后一次提交离现在多久。
git for-each-ref --no-merged=main \
--format='%(committerdate:relative) %(refname:short)' refs/heads
再挨个问你的每一个巡检:它停了以后,会是什么样子?停了和一切正常看上去一样,它就还没把沉默变成信号。
这是“Harness 模式”的第 2 期。Harness 指包在 Agent 外面、约束它怎么干活的那一套:规则、hook、检查脚本、调度。这个系列按 Agent 系统走到了哪一步分五级来排:一个 Claude Code,人在旁边看;开始派子 Agent;几个 Agent 同时改一个仓库;Agent 在后台跑,人不在;跑了几个月。每升一级少信一样东西,这一篇在第四级,要少信的是“没动静就是没事”。
Flowness 是我们自己做的分布式 Harness,也就是让很多个 Agent 节点共用一份事件记录(按时间记下每个决定和结果)、一起完成复杂任务的那套系统。这个系列里的模式,整理自这样一个烧了粗略估计 1400 亿 Claude token,持续运行超过六个月并真实使用在生产交付的系统中所遇到的问题。
附录:最小可跑配置
这是最小可跑版,做法见正文第三到六节,装好以后怎么故意犯一次在本节最后。四份,外加 .gitignore 一行。巡检和死人开关由 cron 跑,cron 的 PATH 只有 /usr/bin:/bin,所以这两个脚本除了 bash 和 git,只用系统自带的命令。在 Claude Code 2.1.302 和 macOS 自带的 cron 上整条跑通过;Linux 上 date -r 文件 同样是取文件的修改时间(GNU date 的写法),我们没在 Linux 上测。
用之前做四件事:
- 四份放进仓库,
chmod +x scripts/*.sh;.gitignore加一行.watch/,巡检的记录和告警都写在这里。 - 主干叫 master 的,在 crontab 里加一行
BASE=master;一小时的门槛在MAX_AGE(秒),死人开关的LIMIT取两个巡检周期多一点。 crontab.txt里R改成仓库的绝对路径,路径带空格也照写,两行里的"$R"已经加了引号。ALERT_CMD换成你的通知,webhook 地址留在单引号里:有的群机器人的地址带&,不加引号,命令会在&处断开,后半截地址和告警正文都会丢掉。群机器人要 JSON 的(比如 Slack),换成crontab.txt里注释着的那行,用 jq 包一层,别的机器人按它要的字段改;/usr/bin里没有 jq 的,先装上,再写它的全路径(cron 的 PATH 只有/usr/bin:/bin)。cron 会把任务打出来的东西发进本机邮箱(macOS 在/var/mail/下),平时没人看,所以 curl 带着-o /dev/null,成功时不出声。- 先在终端手动跑一次
scripts/watch-output.sh,.watch/log里会多出一行late=…:仓库干净时是late=0;有超过一小时没合回的旧分支、改了没提交的旧 worktree,这一行会先把它们列出来,.watch/alerts里也记一条。然后再装 crontab。不先跑,死人开关第一次跑时还找不到记录,会先叫一声“巡检还没有留下过记录”;巡检真的从没跑起来过,它也是这样叫。crontab crontab.txt会替换掉你现有的 crontab,已经有的话,用crontab -e把这几行加进去。仓库放在桌面、文稿、下载这几个目录里的,macOS 可能不让 cron 读,要先在系统设置里给/usr/sbin/cron完全磁盘访问权限,这一条我们没测。
scripts/watch-output.sh:巡检。列出超过 MAX_AGE 秒没合回主干的分支,和改了没提交、改动超过 MAX_AGE 秒没再动过的 worktree,每轮写一行。开头那行 GIT_OPTIONAL_LOCKS=0 让它对 git 只读:不去写 Agent 正在用的 worktree 的 index(暂存区),免得和 Agent 的 git 命令抢锁。
#!/usr/bin/env bash
# 巡检:盯产出。列出两样:有提交、却超过 MAX_AGE 秒
# 没合回 BASE 的分支;改了没提交、改动的文件超过
# MAX_AGE 秒没再动过的 worktree(主目录不看)。
# 每轮都在 .watch/log 写一行,查出 0 个也写;
# git 出错就不写,让死人开关发现。
cd "$(dirname "$0")/.." || exit 1
MAX_AGE=${MAX_AGE:-3600}
BASE=${BASE:-main}
export GIT_OPTIONAL_LOCKS=0 # 只读:不替正在干活的 Agent 写 index
refs=$(git for-each-ref --no-merged="$BASE" \
--format='%(refname:short)' refs/heads) || exit 1
now=$(date +%s); late=""
for b in $refs; do
t=$(git log -1 --format=%ct "$b" --) || exit 1
[ $((now - t)) -gt "$MAX_AGE" ] && late="$late $b"
done
wts=$(git worktree list --porcelain) || exit 1
while IFS= read -r w; do
[ -d "$w" ] || continue
git -C "$w" status --porcelain -z -uall --no-renames \
> /dev/null || exit 1
new=0; dirty=""
while IFS= read -r -d '' l; do
dirty=1; f="$w/${l:3}"
# 文件删了,就往上找还在的目录,看它的修改时间
until t=$(date -r "$f" +%s 2>/dev/null) || [ "$f" = "$w" ]; do
f=$(dirname "$f")
done
[ -n "$t" ] && [ "$t" -gt "$new" ] && new=$t
done < <(git -C "$w" status --porcelain -z -uall --no-renames)
[ -n "$dirty" ] && [ $((now - new)) -gt "$MAX_AGE" ] || continue
b=$(git -C "$w" rev-parse --abbrev-ref HEAD) || exit 1
# 没在分支上(比如 rebase 停在半路),写目录名
[ "$b" = HEAD ] && b=${w##*/} && b=${b// /_}
case " $late " in *" $b "*) continue ;; esac
late="$late $b(没提交)"
done <<< "$(printf '%s\n' "$wts" | sed -n '1d;s/^worktree //p')"
n=$(echo $late | wc -w | tr -d ' ')
mkdir -p .watch
echo "$(date '+%F %T') late=$n$late" >> .watch/log
[ "$n" = 0 ] || scripts/alert.sh "没合回 $BASE:$late"
scripts/deadman.sh:死人开关。只看巡检的记录多久没更新。
#!/usr/bin/env bash
# 死人开关:巡检的记录超过 LIMIT 秒没更新,就叫人。
# LIMIT 取两个巡检周期多一点。
cd "$(dirname "$0")/.." || exit 1
LIMIT=${LIMIT:-3900}
if ! last=$(date -r .watch/log +%s 2>/dev/null); then
scripts/alert.sh "巡检还没有留下过记录"
exit 0
fi
age=$(( $(date +%s) - last ))
if [ "$age" -gt "$LIMIT" ]; then
scripts/alert.sh "巡检已经 $age 秒没有新记录"
fi
scripts/alert.sh:告警出口。记一行,再交给 ALERT_CMD。
#!/usr/bin/env bash
# 告警出口:记进 .watch/alerts,再把这句话交给
# ALERT_CMD(在 crontab 里设,换成你自己的通知)。
cd "$(dirname "$0")/.." || exit 1
mkdir -p .watch
echo "$(date '+%F %T') $*" >> .watch/alerts
if [ -n "$ALERT_CMD" ]; then
printf '%s\n' "$*" | sh -c "$ALERT_CMD"
fi
crontab.txt:两行定时,外加路径和通知。
# R 改成仓库的绝对路径,ALERT_CMD 换成你的通知方式;
# 地址留在单引号里,不然地址里的 & 会把命令截断。
# 两行在同一份 crontab 里:cron 整个停了,两个一起停。
R=/path/to/repo
ALERT_CMD=curl -fsS -m 10 -o /dev/null -d @- '你的webhook地址'
# 机器人要 JSON 的(比如 Slack),上一行换成:
# ALERT_CMD=jq -Rsc '{text: .}' | curl -fsS -m 10 -o /dev/null -H 'Content-Type: application/json' -d @- '你的webhook地址'
*/30 * * * * "$R"/scripts/watch-output.sh
*/10 * * * * "$R"/scripts/deadman.sh
装好以后,故意犯一次:
- 让一个后台 Agent 在 worktree 里提交一个改动、不合回。一个半小时内,你应该收到一条“没合回 main”,后面列着这个分支。
- 把 crontab 里巡检那一行注释掉,等一个多小时(从注释掉算,最长约 70 分钟),你应该收到“巡检已经多少秒没有新记录”。收不到,先看
.watch/alerts:里面有这一条,是你的通知没接通;没有,是 cron 没把脚本跑起来。 - 把那一行改回来。下一轮巡检写了记录,死人开关就不再响;在那之前,它可能还会再响几次。测完先移除第 1 步那个 worktree(
git worktree remove <路径>;里面还有没提交的东西,它会拒绝,确认不要了再加--force),再把分支合回或删掉,不然巡检每半小时还会报它一次。
我们在三个新仓库里照着测过,都把两行改成每分钟跑,巡检门槛缩到 2 分钟,死人开关 150 秒。每次都有一个分支提交了、不合回,三次都是巡检在提交满 2 分钟后的第一轮报了它;前两次另有一个合回主干的分支,一次也没报。停法换着来,两次注释掉巡检那一行,一次把巡检脚本改名,死人开关都在巡检最后一次记录 3 分钟后响了,告警经 curl 送到了本机的一个收件端。最后一次的仓库路径带空格,crontab 照附录这一版写,地址里带 &,收件端收到的地址是完整的。改了没提交那一项另测了两次:后台 Agent 在 worktree 里新建一个文件、不提交,我们在它干到一半时用 claude stop 停掉,巡检门槛同样缩到 2 分钟,改动不满 2 分钟的那几轮记的是 late=0,满 2 分钟后的第一轮报出了它。