jobs.json 里一条脏数据,让全部定时任务一起停了工


周一早上你发现晨报没来。翻到上周三也是空的,再往前一天也没发。你打开 hermes cron list,任务一个不少,next_run_at 看起来也正常,状态里没有刺眼的红字——可它就是不跑。这种「全都不跑」的故障,很可能不是你的任务写错了,而是 ~/.hermes/cron/jobs.json 里混进了一个 null:一个坏条目,就能让整个调度器停工。 2026-09-26 合并的 PR #124034 处理的就是这一类输入,本文讲清楚它怎么坏、现在怎么修、以及你该怎么查。

一个 null 如何让调度器整体停摆

jobs.json 是 cron 的任务库。调度器每次 tick 都会调用 load_jobs() 把文件读一遍;如果读的过程中抛异常,这次 tick 就直接失败——而下一个 tick 会再读一次同一个文件,于是再失败一次。任务本身完全健康,只是永远轮不到它们执行。

修复前有三个相关的坑,每个都能独立造成上面这种局面:

输入 之前的行为 后果
jobs 列表里出现非对象条目(null、字符串、数字) load_jobs() 直接抛异常 每次 tick 都失败,所有任务都不跑(issue #123281)
jobs 字段本身是个标量,例如 {"jobs": null} 原样返回给调用方 调用方拿到 None 后崩在别处
某个任务的 repeat.completed 是 null mark_job_run / update_job 抛 TypeError: NoneType + int 记录任务运行时崩溃(issue #122114)

第三种还带一个很容易被忽略的显示副作用:计数坏掉之后,cronjob 工具会把只跑一次的任务显示成 1/1,hermes cron 会把计数显示成 None/3。数字看起来“有点怪”但不像错误,于是又被人忽略下去。

修复后会发生什么

  • 跳过坏条目,并就地修复文件。 加载器不再因为一条垃圾数据就整体抛异常,而是丢掉非对象条目、把清理后的内容写回磁盘——即使一个合法任务都不剩(那时文件被改写成 [])。
  • 标量 jobs 字段归一化。 {"jobs": null} 这类形状会被替换成 [] 并保存,调用方拿到的永远是列表。
  • 非整数计数归一化。 repeat.completed 只要不是非负整数,就一次性重置成合法计数(读不出来时按 0 处理),之后所有读取方看到的都是整数,1/1 和 None/3 这类显示错误一并消失。
  • 修复在锁内完成。 写回动作会先重新读一次文件、再在任务库锁内保存,避免和并发的 update_job 打架——把对方的改动覆盖掉。这条是评审里被卡住的点,值得单独记一笔。
  • 日志只记类型,不记内容。 修复警告会打印被跳过值的类型(比如“字符串”“null”),但不会把内容打进日志;同一次修复只警告一次,避免刷屏。

官方文档里现在的说法很直白:手改让 jobs.json 变形之后,调度器在下次加载时修复它,而不是停下。

你自己的机器上怎么查

先确认调度器本身在跑、任务有没有逾期:

hermes cron list        # 任务列表与状态
hermes cron doctor      # 一次体检:失败运行、失败投递、逾期的 next_run_at、缺失脚本等

想直接看一眼文件里有没有“不该出现的类型”,可以跑:

python3 - <<'PY'
import json, os
p = os.path.expanduser("~/.hermes/cron/jobs.json")
d = json.load(open(p))
jobs = d.get("jobs")
print("jobs type:", type(jobs).__name__)
if isinstance(jobs, list):
    bad = [(i, type(j).__name__) for i, j in enumerate(jobs) if not isinstance(j, dict)]
    print("entries:", len(jobs), "| non-object entries:", bad or "none")
    for j in jobs:
        c = (j.get("repeat") or {}).get("completed")
        if not isinstance(c, int) or c < 0:
            print("  bad counter:", j.get("name") or j.get("id"), "->", repr(c))
PY

第二条命令是只读的:它不会改你的文件。真正做修复的是 Hermes 自己——升级后再让调度器跑一轮,坏条目会被清掉。

别手改 jobs.json(官方警告,值得重复一遍)

文档里有一条明确提示:管理任务请通过 cronjob_manage 工具、hermes cron edit 或 /cron,不要直接编辑 jobs.json。原因是直改可能静默失败——当路径被文件写入安全策略挡住时(例如设了 HERMES_WRITE_SAFE_ROOT),你的编辑根本没落盘,而“文件变更校验”的页脚才是唯一可信的信号。

也正因为手改会发生,这次修复才有意义:工具路径之外的意外(外部脚本、同步工具、手工修一半的编辑)不该让整台机器的定时任务陪葬。

还没修的部分

PR 里写明了一件故意没做的事:issue #123120 提出的“损坏时宁可写失败(fail closed)“是另一种行为取向——它会让损坏的任务库拒绝写入,而不是被修复。这需要维护者做决定,目前仍在开放状态。也就是说:现在的策略是”自愈优先“。

小结

jobs.json 从“一条脏数据弄死整队任务”变成了“跳过脏数据、就地修复、继续跑”;repeat.completed 从会崩的 null 变成一定会被归一化成整数的计数。如果你正在维护一批无人值守的定时任务,这次升级值得优先做——因为它修的不是某个任务的逻辑,而是整队任务一起沉默这一类最难排查的故障。想继续看这块,可以读一条命令给整队定时任务做体检,跨天调度的时区与 DST 坑以及定时任务现在会记事;命令细节见命令参考 · hermes cron。