fix(cron): normalize any non-int repeat.completed, not only null

A hand-edited "completed": "2" still crashed every recorded run ("2" + 1),
and 1.0 was stored as 2.0 ("2.0/3"). load_jobs now coerces any non-int
counter to a non-negative int (0 when unparseable). Document the load-time
repair next to the direct-edit tip.

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
This commit is contained in:
kshitijk4poor
2026-09-26 21:44:40 +05:30
committed by kshitij
parent 02ddb846a1
commit 06a495cc5b
4 changed files with 23 additions and 9 deletions

View File

@@ -1391,12 +1391,16 @@ def load_jobs() -> List[Dict[str, Any]]:
jobs = [j for j in jobs if isinstance(j, dict)] jobs = [j for j in jobs if isinstance(j, dict)]
repair = repair or "non-object entries dropped" repair = repair or "non-object entries dropped"
for job in jobs: for job in jobs:
# A hand-edited "completed": null would crash every counter reader (None += 1, None >= n) # A hand-edited "completed" that is not an int (null, "2", 1.0) would crash every counter
# and render as "None/3"; normalize it once here so readers can trust an int. # reader (None += 1, "2" + 1) or render as "None/3" / "2.0/3"; normalize it once here so
# readers can trust an int.
rep = job.get("repeat") rep = job.get("repeat")
if isinstance(rep, dict) and "completed" in rep and rep["completed"] is None: if isinstance(rep, dict) and "completed" in rep and type(rep["completed"]) is not int:
rep["completed"] = 0 try:
repair = repair or "null repeat.completed reset to 0" rep["completed"] = max(int(rep["completed"] or 0), 0)
except (TypeError, ValueError):
rep["completed"] = 0
repair = repair or "invalid repeat.completed normalized"
# Persist even an empty result, or an all-junk store repeats the repair on every tick. # Persist even an empty result, or an all-junk store repeats the repair on every tick.
if repair: if repair:
if not getattr(_jobs_lock_state, "depth", 0): if not getattr(_jobs_lock_state, "depth", 0):

View File

@@ -446,16 +446,22 @@ class TestJobCRUD:
job = create_job(prompt="t", schedule="every 1h", repeat=3) job = create_job(prompt="t", schedule="every 1h", repeat=3)
def null_completed(): def set_completed(value):
payload = json.loads(JOBS_FILE.read_text(encoding="utf-8")) payload = json.loads(JOBS_FILE.read_text(encoding="utf-8"))
payload["jobs"][0]["repeat"]["completed"] = None payload["jobs"][0]["repeat"]["completed"] = value
JOBS_FILE.write_text(json.dumps(payload), encoding="utf-8") JOBS_FILE.write_text(json.dumps(payload), encoding="utf-8")
null_completed() set_completed(None)
mark_job_run(job["id"], success=True) mark_job_run(job["id"], success=True)
assert get_job(job["id"])["repeat"]["completed"] == 1 assert get_job(job["id"])["repeat"]["completed"] == 1
null_completed() set_completed(None)
assert update_job(job["id"], {"repeat": {"times": 5}})["repeat"]["completed"] == 0 assert update_job(job["id"], {"repeat": {"times": 5}})["repeat"]["completed"] == 0
# Other hand-edited shapes: a string would crash ("2" + 1), a float would render "2.0/5".
for value, expected in (("2", 3), (1.0, 2), ("junk", 1)):
set_completed(value)
mark_job_run(job["id"], success=True)
completed = get_job(job["id"])["repeat"]["completed"]
assert completed == expected and type(completed) is int
def test_oneshot_turned_recurring_becomes_forever(self, tmp_cron_dir): def test_oneshot_turned_recurring_becomes_forever(self, tmp_cron_dir):
"""A one-shot budget must not survive a schedule change to a recurring kind. """A one-shot budget must not survive a schedule change to a recurring kind.

View File

@@ -1349,6 +1349,8 @@ Job definitions are plain JSON on disk: they survive `hermes update`, gateway re
Ask the agent to manage jobs through the `cronjob_manage` tool, `hermes cron edit`, or `/cron` — not by patching `jobs.json` directly. Direct edits can fail silently when [file write safety](../security.md#file-write-safety) blocks the path (for example when `HERMES_WRITE_SAFE_ROOT` is set), and the [file-mutation verifier](../configuration.md#file-mutation-verifier) footer is the authoritative signal that nothing was saved. Ask the agent to manage jobs through the `cronjob_manage` tool, `hermes cron edit`, or `/cron` — not by patching `jobs.json` directly. Direct edits can fail silently when [file write safety](../security.md#file-write-safety) blocks the path (for example when `HERMES_WRITE_SAFE_ROOT` is set), and the [file-mutation verifier](../configuration.md#file-mutation-verifier) footer is the authoritative signal that nothing was saved.
::: :::
If a hand edit leaves `jobs.json` malformed, the scheduler repairs it on the next load instead of stopping: entries in the `jobs` list that are not JSON objects are dropped, and a `repeat.completed` that is not an integer is normalized to one. Each repair is logged as a warning (value types only, never contents).
Jobs may store `model` and `provider` as `null`. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set. Jobs may store `model` and `provider` as `null`. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set.
The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind. The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.

View File

@@ -742,6 +742,8 @@ cronjob(action="create", name="daily-digest",
任务存储在 `~/.hermes/cron/jobs.json`。任务运行的输出保存到 `~/.hermes/cron/output/{job_id}/{timestamp}.md`。 任务存储在 `~/.hermes/cron/jobs.json`。任务运行的输出保存到 `~/.hermes/cron/output/{job_id}/{timestamp}.md`。
如果手动编辑使 `jobs.json` 格式出错,调度器会在下次加载时修复它,而不是停止运行:`jobs` 列表中不是 JSON 对象的条目会被丢弃,不是整数的 `repeat.completed` 会被规范化为整数。每次修复都会记录一条警告(只记录值的类型,不记录内容)。
任务可能将 `model` 和 `provider` 存储为 `null`。省略这些字段时,Hermes 在执行时从全局配置中解析它们。只有设置了单任务覆盖时,这些字段才会出现在任务记录中。 任务可能将 `model` 和 `provider` 存储为 `null`。省略这些字段时,Hermes 在执行时从全局配置中解析它们。只有设置了单任务覆盖时,这些字段才会出现在任务记录中。
存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。 存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。