OpenClaw Studio · 事故复盘
第一天凌晨醒来,我打开飞书,一片寂静。再点开本地目录——4 份草稿、4 份研究 memo(备忘录)已经在那了。任务全跑了,但没有任何一条通知告诉我。
前一晚我刚装好 OpenClaw Studio 的 7 天 trial(试运行)——一个 LaunchAgent(macOS 的开机后台服务),每 30 分钟唤醒一次,按时间表去触发苏晚、霍锐、沈知行这几个 Agent(智能体)。我本来的想法是:先让它跑两天,看看节奏。如果稳,就上更复杂的事情。
结果它在第一天凌晨就告诉我——稳是个奢侈品。
我装了什么
Trial 这一层做的事情很简单:一个 LaunchAgent 每 30 分钟唤醒一次 runner(实际执行调度的 Python 脚本),runner 翻一遍 schedule(时间表),到点了就触发对应的 Agent,把 output(输出)写到本地目录,再给我发条飞书通知。
早晨这一段是密度最高的——06:50 苏晚开始写早报草稿;07:30 第二个任务;08:40 第三个;10:30 第四个。每一个都有自己的 owner、自己的 output 文件、自己应该发的飞书消息。整个系统的设计是「我醒过来打开手机,飞书里 4 条通知按时间排好,我点哪条看哪条」。
设计是这么写的。第一天凌晨发生的事完全不是。
凌晨的 4 个"半挂"
06:50 那条任务确实跑了——苏晚的早报草稿写出来了,文件在该在的位置,时间戳对得上。07:30 跑了。08:40 跑了。10:30 也跑了。Output 文件 4 份,returncode(命令执行的退出码)全是 0。
但飞书里什么都没有。
一开始我以为是飞书 bot 出了问题——token 过期?webhook 改了?翻日志才发现,每一条飞书通知都炸了,错误信息一模一样:
env: node: No such file or directory
Node 找不到了。我盯着这行看了几秒——明明我刚才在终端里 which node 还好好的,/opt/homebrew/bin/node,路径清清楚楚。怎么 runner 跑到一半,发飞书的时候说找不到 node?
更别扭的是这个「半挂」的形态——业务全跑通了,通知全死了。它不是"系统挂了"那种干脆的事故,是"系统一部分好好的、另一部分悄悄烂掉"的事故。output 真的在;草稿真的写完;研究 memo 也真的产出来了。我不主动翻本地目录,根本不知道这些东西已经在了。
一个调度系统,最怕这种"我以为它没做,其实它做完了;我以为它做完了,其实它根本没跑"。这种事故最狠的地方——你会彻底失去对系统的信任。
根因:LaunchAgent 的 PATH 不是"和你的终端一样"
排查时,我先去看 runner 自己的环境。Runner 是 Python 写的,在 venv(Python 虚拟环境)里跑——Python 解释器路径写死,依赖也都在 venv 里,runner 自己能起得来。
但 runner 在发飞书的时候不是直接调 Python——它 exec(执行)一个叫 openclaw 的 CLI 命令。这个 CLI 是 Node 写的,shebang(#!/usr/bin/env node 这一行的统称,告诉系统用哪个解释器跑脚本)是 #!/usr/bin/env node。
问题出在这。env node 要去 PATH(操作系统找命令的搜索路径)里找 node。终端里的 PATH 是我 shell 配置一层层叠出来的——/opt/homebrew/bin、/usr/local/bin、各种语言版本管理器、各种自己的 bin 目录,加起来一长串。可 macOS launchd 启动的 LaunchAgent 默认 PATH 极其干净,只有这 4 条:
/usr/bin:/bin:/usr/sbin:/sbin
Apple Silicon 上的 Homebrew 默认装在 /opt/homebrew/bin。这条目录不在 LaunchAgent 的 PATH 里。所以 env node 死活找不到 node,shebang fast-fail(一上来就失败),整个 openclaw CLI 一行都没跑就退出了。
很多人第一次踩这个坑都不敢相信——"明明我的终端能跑啊"。大家潜意识里总觉得,自己的电脑就是"自己的电脑",PATH 应该全局都一样。但 LaunchAgent 不是你的终端。LaunchAgent 是 launchd 拉起来的子进程,环境变量是 launchd 自己定义的,跟你的 shell 配置没关系。
最阴的是——业务 Agent 没事。苏晚跑的是 Python venv,路径写死;霍锐也是 venv 里的 Python;沈知行也是。它们都不依赖 PATH 找解释器。业务层照样跑通,output 照样写出。只有通知层——那个用 Node CLI 发飞书的层——挂了。
业务跑通、通知全炸,于是有了"全部成功又全部失败"的奇景。
修了根因,还是要写 watchdog
根因 fix 其实只有一行——在 runner 的 module import(Python 模块导入时执行的代码)阶段,把 /opt/homebrew/bin prepend 到 PATH 里。下一次 LaunchAgent 唤醒 runner,runner 一加载就把 PATH 修好,openclaw CLI 就能找到 node 了。
问题是——只修这一行,够吗?
我犹豫了几分钟,决定不够。
原因很简单。今天炸的是 Node CLI 找不到 node。下一次可能是别的——某个 Python 包 import 时去找系统 binary,找不到;某个第三方工具升级路径变了,runner 里写死的路径过期;某次 macOS 更新悄悄改了 launchd 的环境变量;甚至飞书自己出 5 分钟问题。这些事不会用"node not found"这种一眼能看的形式出现,它们会以各种新姿势出现——但形态都是同一种:业务跑通,交付通知漏掉。
根因可以一次一次修,但"半挂"作为一种事故模式不会消失。单修 PATH 是 root cause(根本原因)fix,watchdog(看门狗,事故时自动救场的兜底进程)是另一层——它不解决某个具体的根因,它只给所有未来的"半挂"事故一个自动救场的机会。
Runner v2.3 就是在这个想法下写出来的。它一共四块改动:
- 第一块,根因 fix——module import 时把 Homebrew 路径 prepend 进 PATH,今天的事故不再以这个形式出现。
- 第二块,重试通道——retry_pending_notifications,每次 LaunchAgent 唤醒时扫一遍最近的任务,发现"output 在但 notification 没发"的,自动 retry(重试),每个 task 最多 retry 4 次。
- 第三块,deterministic watchdog——每次唤醒主动检查 4 类问题:task_missed(任务没跑)、output_missing(output 应该在但不在)、notification_missing(output 在但通知没发)、boundary_fail(跨边界的状态不一致)。发现就发一条去重的飞书 alert(告警),告诉我"发生了什么、在哪、什么时候发生的"。
- 第四块,Codex watchdog checkpoint——在一天里 6 个固定时刻跑一次 Codex(OpenAI 的 CLI agent)的 exec,read-only sandbox 里审计当天的所有调度状态,写 markdown + JSON 的 checkpoint(检查点),加发一条飞书 summary。
第二块和第三块是对称设计——重试通道是"我看到漏了,自动救回";deterministic watchdog 是"我看到漏了,告诉你"。两者都是兜底,不是首选路径。首选路径永远是 runner 第一次发通知就成功。
第四块 Codex watchdog 多一层意义——deterministic watchdog 只能识别我能预见的事故类型;Codex watchdog 能识别我没预见的、需要语义理解才能发现的事故。代价是它贵、慢、需要外部依赖。所以它的频率是"6 次/天"——早晨业务密集时勤一点,下午和晚上稀一点。
Catch-up:4/4 自动救回
v2.3 装上去之后,我没急着发新通知。我手动触发了一次 LaunchAgent 唤醒——让 retry 通道扫一遍今天凌晨那 4 条死掉的通知。
扫描结果:4 个 task 都被识别成 notification_missing;都有 output 文件,都有正确的时间戳;都符合 retry 条件。
Retry 一遍。4 条飞书通知,按时间顺序,按原本应该发出去的样子,一条一条到了。returncode 全是 0。
最让我安心的是这句——「没重跑 agent,没覆盖已有输出」。retry 只发通知,不重新触发业务。设计时就定了这个约束——业务任务里有不可逆的操作(写历史账本、追加 audit 日志),重跑会污染状态。Catch-up(事后追跑漏掉的事情)的边界永远只在"通知层"——业务那层已经做完了,不能再动。
4/4 自动救回。fix commit 是 e752a93。
那一刻的感受很奇特——事故发生了,事故被识别了,事故被自动补救了,整个过程我只手动触发了一次唤醒,剩下的全是系统自己做的。事故没被藏起来,也没被放大。我第一次切身体会到 watchdog 真正的价值——它不创造新功能,只让事故的恢复成本接近 0。
这件事让我学到了几件事
复盘到最后,能写进规则里的就这几条——之后再踩类似的坑,我希望自己能直接想到这些。
- LaunchAgent 的 PATH 不是"和你的终端一样"。这是个老坑,但每次新装 LaunchAgent 我还是会想当然以为环境是一样的。下次再装 LaunchAgent,第一件事是把 PATH 显式写清楚——要么 plist 里写,要么 runner 第一行 prepend。不要假设"应该没事"。
- "业务跑通"和"交付完成"是两件事。output 落地只是中间状态。真正的交付是"用户收到通知 + 用户能找到 output"。中间任何一环断了,都算交付失败——不管 output 写得多漂亮。下次写调度系统,把"交付"作为最后一道关,比"任务执行成功"更严格。
- 根因 fix 和兜底层要分开做,但要一起上线。根因 fix 是 PATH,防止今天这个事故。兜底层是 retry + watchdog,防止以后所有形态相似的事故。两者不互相替代。只修根因,下次新形态的"半挂"还会让我手动救场;只加兜底,今天这个事故每次唤醒都会触发一次 alert,最后变噪音。
- watchdog 是"事故恢复成本接近 0"的保险。事故没发生时它像浪费。价值只在事故发生的那一瞬间——它把"我醒来手动排查 2 小时"变成"系统自己处理完,发我一条 summary"。买这个保险的成本是写代码的时间;不买的成本是未来某天的某 2 个小时。
所以现在 v2.3 跑成什么样
v2.3 已经跑了好几天。Day 1 之后的事故密度反而比想象中高——倒不是 PATH 问题再出现,别的形态的"半挂"开始冒头。runner 的 retry 通道兜住了大部分;deterministic watchdog 发了几条 alert,每次我都能在 5 分钟内判断要不要介入;Codex watchdog checkpoint 写了一堆 markdown,给了我一份"每天系统大致跑成什么样"的全景视图。
但也有几个地方还没收口。
Deterministic watchdog 的去重粒度还在调——有时候同一个 task 会被识别成两次不同的 missed,飞书里就收到两条几乎一样的 alert。不致命,但会污染信号。理想状态是"同一次事故只 alert 一次,除非状态真的变了"。
Codex watchdog checkpoint 的频率(6 次/天)现在是固定的,但早晨密集、下午稀少。下一步我想让它按"事故密度"动态调——前一段时间事故多就勤一点,没事故就稀一点。但这得先定义"事故密度",目前还没。
最大的一摊——v2.3 修的是 trial 这层调度的通知通道。但 6 个 Agent 各自的"业务通知"通道在后面几天又有别的坑——业务消息从哪个 bot 发、用谁的身份、消息怎么排版、谁应该收到、谁不应该收到。另开一篇说。
第一天凌晨的事故,把"加 watchdog"这件本来排在第二周的事,提前到了第一天下午。Trial 给我的第一个礼物——没让我等一周才发现问题。
Trial 不是为了证明系统能跑,是为了在低成本的环境下让系统的脆弱点全部暴露出来。每暴露一个,我修一个;每修一个,watchdog 长大一点。Day 1 长出 PATH fix 和 retry 通道;后面几天长出别的东西。每一天的 watchdog 都比前一天稍微更聪明一点。
事故是常态,watchdog 也在长。我不再期待"装完就稳"——我期待的是"每次事故都让系统下次自己救场的能力强一点"。在这之前,我还在改。