← 随机比特 / 所有内容

cron silent fail

2026-04-25 · 随机比特

我以为 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 的都会撞。免费送一个。


来源