定时任务静默失效的两种方式:线程没启动,和删掉的任务自己回来


周一早上你打开电脑,周末本该跑的三条定时任务一条都没跑。hermes cron list 里任务都在,状态正常,日志里没有报错,也没有“失败”记录——什么都没发生,因为调度器根本没有运行。

定时任务的失效几乎总是静默的:它不像模型调用失败会重试、会报错、会弹提示,它只是不干活。最近有两起独立报告,正好代表两种最隐蔽的形态:一种是线程从未启动,另一种是你删掉的任务自己长了回来。

形态一:cron 线程在导入时就死了

桌面版的后端(hermes dashboard 进程)里跑着一个进程内 cron ticker,负责在没有 gateway 的情况下也能让定时任务触发。它的入口在 hermes_cli/web_server.py 的 _start_desktop_cron_ticker,第一件事是 from cron.scheduler_provider import ...。

在 Python 3.14 上,这个 import 偶尔会撞上 CPython 的模块导入死锁:

=== thread exception · 2026-10-24 23:23:13 · thread=desktop-cron-ticker ===
  File ".../hermes_cli/web_server.py", line 107, in _start_desktop_cron_ticker
    from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler
  File ".../cron/__init__.py", line 13, in <module>
    from cron.jobs import (...)
_frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cron.jobs')

关键在最后一行之外:这个异常是在线程的第一条语句里抛出的,所以线程里的重试、降级、告警逻辑一个都没机会跑。ticker 在这个进程里等于不存在,直到进程重启。报告者在一台 macOS 机器上 8 天内撞了 15 次,锁名在 cron.jobs 和 cron.executions 之间跳。

为什么它特别难发现?因为同一个安装里通常还有一个 launchd(或 systemd)托管的 gateway 也在 tick cron 存储,于是任务照常触发,桌面端的死线程毫无症状。只有当“桌面后端是唯一调度者”时——单机、只开 App、没有独立 gateway——才会变成真正的定时任务停摆,而且是静默停摆。

好消息是它有痕迹:崩溃日志在 ~/.hermes/logs/tui_gateway_crash.log,搜 thread exception 和 desktop-cron-ticker 就能找到。这个线程死锁已被记进更早的追踪条目(#134889 的 Lane 3),修法方向也是现成的:把 cron 存储的导入放到主线程、在 ticker 线程创建之前完成,用一个单线程的导入阶段把竞态消掉。

形态二:删掉的定时任务被写回磁盘

第二种更气人:你执行了 hermes cron remove <id>,hermes cron list 里确实没了,两次手动检查都干净。过了一天,它又出现在磁盘上——字节级完全一样,包括 created_at、state=completed、last_run_at。一个已经完成的一次性任务,没有任何路径会重新创建它,所以这只可能是某个“记得它还在”的写入者把自己内存里的旧副本刷回了磁盘。

机制在 cron/jobs.py 的合并逻辑里。_merge_unexpected_disk_jobs / _unmerged_disk_jobs 会保护“磁盘上有、但这次保存没有”的行——这是创建方向的保护,而且会写日志(#80624 加的守卫)。但删除方向(保存里有、磁盘上没有)是无条件写回的,也没有日志。一个在删除之前加载过 jobs.json 的长生命周期写入者,收尾时把整个 store 保存回去,删除就被静默撤销了。

报告里这次事故的代价很具体:那条复活的一次性任务指向一个已经退役的脚本,把一个夜间套件又跑了一遍,触发了两个监控告警和一次 RCA。它发生在 15 个 cron store、一个复用 gateway 的多 profile 环境里——写入者越多,撞上的概率越高。

提出的修法是“删除墓碑”:任何带 removed_ids 的保存,把这些 id 加删除时间写进每个 store 旁边的 cron/jobs.removed.json;之后所有合并式保存都从载荷里减掉墓碑里的 id(除非某行的 created_at 晚于删除时间,允许 id 回收后重建),并且这次要写日志。用旁挂文件而不是 jobs.json 里的元数据,是为了让 replace=True 的修复路径和外部整文件重写都抹不掉删除意图。

出问题时按这个顺序查

三条命令加一个文件,基本能定位到是哪一类:

hermes cron status        # 调度器到底在不在跑
hermes cron doctor        # 常见健康问题体检
hermes cron runs          # durable 执行记录:有没有真的跑过
hermes cron incidents     # 失败事件的持久记录

再加一条人工检查:~/.hermes/logs/tui_gateway_crash.log 里搜 thread exception。如果 status 说没有调度器、runs 里最近几天一片空白,而崩溃日志里有 desktop-cron-ticker,那就是形态一。如果任务跑过、或者任务“莫名回来了”,看 jobs.json 里那一行的 created_at 和 last_run_at 是否与你知道的一致——那是形态二。

四条预防措施

  • 一个 store 只有一个调度者。 桌面后端、launchd gateway、systemd 服务同时 tick 同一个 store,是“看不见的写入者”最主要的来源。留一个,其余停掉。
  • 删完等一轮再确认。 hermes cron remove 之后,过几分钟(或重启一次调度器)再看 hermes cron list,比当场看更可靠——竞态发生在收尾保存,不在删除那一刻。
  • 重要任务别只靠 cron 一个信号。 让任务自己往外部发一条“我跑完了”(webhook、消息、写一行文件),比事后查 hermes cron list 可靠得多。
  • 升级后第一件事是 hermes cron status。 尤其是 Python 版本变了、或者换了调度方式之后。

一句总结

定时任务的失败没有声音,所以你必须主动去听。线程有没有起来、任务有没有真跑、删掉的东西有没有留在磁盘上——这三件事都各有一个廉价检查。把检查做成习惯,比等周一早上发现全都没跑要好得多。

延伸阅读:cron 健康检查指南、取消正在运行的定时任务、定时任务的记忆连续性、v0.22.7 这个窗口里还有什么。