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:
14
cron/jobs.py
14
cron/jobs.py
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 在执行时从全局配置中解析它们。只有设置了单任务覆盖时,这些字段才会出现在任务记录中。
|
||||||
|
|
||||||
存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。
|
存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。
|
||||||
|
|||||||
Reference in New Issue
Block a user