我以为 claude -p rc=0 就是成功,今天踩了三层坑
凌晨 7:30 Telegram 又响:
daily-digest-finish 静默失败(state 未推进) 期望 stage=done date=2026-04-24,实际 stage=done date=2026-04-23 claude -p rc=0 但 STATE.yaml 未更新——可能是 quota 静默截断、硬门控未过、或幻觉式总结。
报警文案和那三种可能性都是我自己写的。今天追下去发现,三种都不是。真正的根因藏在第三层,每翻一层都比上一层反直觉。记一下,因为这个坑大多数让 LLM 跑生产 cron 的人迟早会撞。
一层:以为是 date 漂移
打开日志,claude 自己说得言之凿凿:
Turn 2 完成。公众号草稿已注入成功:
标题"看 Star 数选库?你这不叫技术评估,叫买彩票",
正文 2488 字,封面 37.7 KB
[runner] === finish end rc=0 2026-04-24 07:44:16 ===
[runner] sanity FAIL: 期望 stage=done date=2026-04-24,实际 stage=done date=2026-04-23
第一直觉:claude 是不是幻觉了?没真注入?
去公众号草稿箱查 —— 草稿真在,标题字数封面全对得上。所以不是幻觉,是 STATE 里的 date 写错了。
翻 git log 发现 04-24 早上 8:46 已经有一个修复 commit:
fix(daily-digest): cron 注入今日日期到 system prompt,防 date 漂移
根因:claude 主 agent prompt 里没有"今天是哪天"的权威信息。模型看到 workspace 里 KB / trending / drafts 全是 04-23 的产物,就推断"今天就是 04-23",把 STATE.date 也写成 04-23。
修法:
--append-system-prompt里硬塞一段权威日期注入。
OK,04-24 的 case 已经修过了。本以为今天的报警是同一个问题——关掉报警走人。
但用户问的是"今天"。
二层:今天是另一个根因
今天(04-25)的日志看上去更怪:
fetch end rc=0 2026-04-25 06:33:33
write log: stage=idle date=2026-04-25
finish log: stage=fetched date=2026-04-25
date 是对的(04-24 那个 fix 生效了)。但 stage 卡在中间没推进。
write 阶段的硬门控写在 prompt 里:「stage 不是 fetched → 直接结束」。它看到 stage=idle 直接退了。可 fetch 是 rc=0 啊,stage 应该已经是 fetched 了?
把所有时间戳排出来:
05:00:01 fetch cron 启动
05:00:01 fetch pre-flight reset → STATE = idle/2026-04-25
05:02 trending-2026-04-25.json 写出
06:30:01 ← write cron 启动 —— 但 fetch claude 主 agent 还在跑
06:30:15 write claude -p rc=0 退出(只跑了 14 秒)
06:33:29 fetch claude 才把 STATE 改成 fetched/2026-04-25
06:33:33 fetch 进程退出
07:30:01 finish cron 启动 —— 看到 stage=fetched(不是 drafted)也直接退出
fetch 跑了 1 小时 33 分钟,跨过了 06:30 cron 启动 write 的窗口。write 启动时 STATE 还是 pre-flight reset 留下的 idle,里面的 claude 看一眼前置态不符,14 秒就 NO_REPLY 退出。等 fetch 终于在 06:33 把 STATE 写到 fetched,已经晚了。下游 finish 同样级联失败。
三段连环 sanity FAIL,看上去像「静默失败」,实际是 cron 时间窗与 stage 实际耗时不匹配的竞态。
这一层的反直觉点:claude 在硬门控判定不通过时确实做了「按设计的事」——它就是按 prompt 里写的「不通过则结束」做的,rc=0 完全合规。问题是 wrapper 层把这种「按设计退出」当成了「成功完成」。
三层:根因不是 cron 不是 claude,是状态机层级
到这里很多人会去调 cron 时间——把 write 改 7:00、finish 改 8:30。或者加 flock 防并发。
这两个方案都是贴胶布。
真正的根因是两个,都跟时间无关:
一、cron 时间窗刚性假设了 stage 耗时上限。 fetch 原本按 5 分钟跑设计,今天跑了 93 分钟。LLM 主 agent 偶发因 subagent 调用 / 工具卡住 / 上下文堆积导致超时是常态,不是异常。任何「假设 stage 在 X 分钟内完成」的 cron 都会在某天破防。
二、状态机的「前序就绪」判断放错了层。 当前是写在 claude prompt 里(「stage 不是 fetched → 直接结束」),让 LLM 主 agent 自己判断要不要跑。这就出现「按设计退出 rc=0」被 wrapper 判成「成功」的灰色地带。判断逻辑应该在 wrapper 层硬做,不达标 → notify+skip exit 0,不要走「假成功」路径。
第二条尤其关键。LLM agent 是不可靠的状态机执行者——它会把「按规则跳过」和「真做完了」输出成同样的「Turn 2 完成」格式,rc 同样是 0。你让它自己判前置态,就要接受偶尔的「假成功」。
这其实跟把 input validation 写在 LLM system prompt 里让模型自己 reject 是同一个反模式:把控制流职责下放给一个语义层,控制流就再也回不到硬规则。健全的做法永远是——能在编译期抓的别留给运行时,能在 wrapper 抓的别留给 prompt,能在 prompt 抓的别留给 LLM 自由发挥。LLM 是最后一道防线,不是第一道。
修法:选 chain,不选 flock
到这一步对应三个候选方案:
| 方案 | 思路 | 致命问题 |
|---|---|---|
| 调时间 | write 推到 7:00,finish 推到 8:30 | 治标不治本——下次 fetch 跑 2h 还是会破 |
| flock + wrapper 检查 | 三段保留,加文件锁防并发 + 在 wrapper 层硬校验 stage 前置态 | 解决重叠,不解决「前序未完成 write 还得继续跑」;让 write 等锁只是把崩溃推后;状态机判断逻辑要在三个地方维护 |
| 串行 chain | 单一 cron 入口,依次跑三段 | 失去固定 publish 时间窗 |
我选了第三个。理由:失去固定时间窗在这个场景里其实无所谓——内容 7 点还是 8 点 publish,对读者没区别;而前两个方案都是在硬维持「三段独立调度」这个其实没必要的约束。
新形态:换 cron 形态,单一入口:
0 5 * * * bash run-pipeline.sh
run-pipeline.sh 依次跑 fetch → write → finish,任一段非零退出就停:
for STAGE in fetch write finish; do
bash run-stage.sh "$STAGE"
RC=$?
if [ "$RC" -ne 0 ]; then
echo "[pipeline] !!! $STAGE 失败 rc=$RC,pipeline 停"
exit "$RC"
fi
done
这种结构有个隐含好处——串行依赖天然把「前序就绪」约束硬编码进了控制流,不需要 wrapper 检查。fetch 没退出 write 就不会启动;fetch 失败 chain 就停。状态机判断从「在 LLM prompt 里软问」变成「在 shell 里硬强制」。
附带改动:claude -p 外层加 timeout 5400(90 分钟)兜底,防单 stage 无限拖累 chain。
代价:finish 不再保证固定时间出。原本 7:30 publish,现在是「fetch+write 完了就 publish」,最坏情况 fetch 撞 90min timeout 也能在 7:30 前完成。比「刚性时间窗 + 静默失败」靠谱得多。
顺手踩到的 bash 坑
写完 pipeline 跑测试,三段都通了,但 finish 因为公众号登录态过期失败。pipeline 输出:
[pipeline] !!! finish 失败 rc=0,pipeline 停
等等——rc=0 还能「失败」?我看了眼 pipeline 退出码:0。
代码原版:
if ! bash "$RUNNER" "$STAGE"; then
RC=$?
echo "[pipeline] !!! $STAGE 失败 rc=$RC,pipeline 停"
exit "$RC"
fi
bash 的 if ! cmd; then 进入 then 块时,$? 不是 cmd 的退出码,而是 ! cmd 这个测试命令的退出码——! 反转了原始 rc 让 if 看到 0 才进 then 块,$? 在 then 块里读到的就是 0。
$ if ! false; then echo $?; fi
0 # ← 不是 1
修法:拆成两步。
bash "$RUNNER" "$STAGE"
RC=$?
if [ "$RC" -ne 0 ]; then ... fi
这是个老坑,但写 pipeline 当下没察觉,因为「if cmd 失败就退出」的语义自然让人套 if ! cmd。如果上游有人靠 pipeline 退出码判断成败——比如下游再嵌一个 chain 或者 systemd OnFailure——失败信号就丢了。
两条带走的话
写到这里 1700 字,留两个以后会回头查的 takeaway:
一、不要让 LLM 主 agent 做状态机的「前序就绪」判断。 它会把「按规则跳过」和「做完了」输出成同样格式,rc 同样是 0。状态机判断必须在 wrapper / 控制流层硬做,做不达标就 skip+notify,不要走「假成功」路径。
二、cron 调度别假设 stage 耗时上限。 LLM agent 偶发跑超时是常态。要么改成串行 chain(依赖关系硬编码),要么 flock + timeout(保留并发能力但单点限速)。「假设 fetch < 90 分钟」那种隐式合同总有一天破防。
至于 bash if ! cmd 的 $? 陷阱——大多数人不会写状态机 cron,但凡写 shell 的都会撞。免费送一个。
来源
- 这次 cron 的全部日志:
/data/daily-digest/logs/{fetch,write,finish}-2026-04-2[4-5]_*.log - 修复 commit:
run-stage.sh加timeout 5400,新增run-pipeline.sh - bash
if ! cmd的$?行为:bash man page 的 Pipelines 章节