Files
hermes-agent/cron/job_definition.py
kshitijk4poor d2b0b7ea82 fix(cron): normalize a null repeat.completed once in load_jobs
A hand-edited "completed": null was patched with `.get("completed") or 0`
at five arithmetic readers. The display readers were still missed: the
cronjob tool listed a never-run one-shot as "1/1", and `hermes cron`
printed "None/3". Any new reader would bring the bug back.

Every reader gets its jobs through load_jobs: mark_job_run, update_job,
claim_dispatch, the due scan, merge_job_definition, list_jobs/get_job for
cronjob_job_args._repeat_display and hermes_cli/cron._job_rows. So reset a
null to 0 once in the load_jobs repair pass, which also persists the fix,
and change the per-site `or 0` patches back to their base form.

Co-authored-by: mochamgx <1114149@qq.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-26 22:04:17 +05:30

78 lines
3.4 KiB
Python

"""Job-definition schema shared by importers of a foreign cron store.
Cron owns which persisted fields are *authored* (create_job) versus *advanced by the
scheduler* (next_run_at, state, run counters...). Callers merging an authored store
into a live one (profile distributions) import this rather than duplicating the list.
"""
from typing import Any, Dict
from cron.jobs import _apply_schedule_update, _jobs_lock, is_job_runnable, load_jobs, parse_schedule, save_jobs
from cron.quota_hold import clear_state as _clear_quota_hold
from hermes_time import now as _hermes_now
JOB_DEFINITION_FIELDS = frozenset({
"name", "prompt", "skills", "skill", "model", "provider", "base_url",
"script", "no_agent", "monitor_script", "monitor_url", "context_from",
"schedule", "schedule_display", "deliver", "origin", "enabled_toolsets",
"workdir", "attach_to_session", "reasoning_effort", "failure_deliver",
})
def merge_job_definition(local: Dict[str, Any], authored: Dict[str, Any]) -> Dict[str, Any]:
"""Refresh authored fields while preserving this store's scheduler-owned state.
Raises ValueError when the authored schedule cannot be scheduled (unparseable string,
past one-shot for a live job)."""
merged = {key: value for key, value in local.items() if key not in JOB_DEFINITION_FIELDS}
merged.update((key, authored[key]) for key in JOB_DEFINITION_FIELDS if key in authored)
merged["repeat"] = {
"completed": (local.get("repeat") or {}).get("completed", 0),
"times": (authored.get("repeat") or {}).get("times"),
}
if isinstance(merged.get("schedule"), str):
merged["schedule"] = parse_schedule(merged["schedule"])
if local.get("schedule") != merged.get("schedule"):
merged.pop("pending_slot", None)
_clear_quota_hold(merged)
if is_job_runnable(merged):
updates = {"schedule": merged["schedule"]}
if "schedule_display" in authored:
updates["schedule_display"] = authored["schedule_display"]
_apply_schedule_update(merged, updates, str(merged.get("id") or "imported job"))
else:
merged["next_run_at"] = None
return merged
def import_job_definitions(shipped: Dict[str, Dict[str, Any]], *, paused_reason: str) -> None:
"""Merge *shipped* (job id -> authored record) into the active store under its lock.
Unknown ids arrive with the marker set ``create_job(paused=True)`` writes; known ids keep
their scheduler state. Nothing is written when a record cannot be merged: the ValueError
is re-raised naming the job."""
now = _hermes_now().isoformat()
seed = {
"enabled": False,
"state": "paused",
"paused_at": now,
"paused_reason": paused_reason,
"created_at": now,
"next_run_at": None,
}
pending = dict(shipped)
with _jobs_lock():
merged = []
for local in load_jobs():
incoming = pending.pop(local.get("id"), None)
merged.append(local if incoming is None else _merge_or_name(local, incoming))
merged.extend(_merge_or_name({"id": job_id, **seed}, incoming) for job_id, incoming in pending.items())
save_jobs(merged)
def _merge_or_name(local: Dict[str, Any], authored: Dict[str, Any]) -> Dict[str, Any]:
try:
return merge_job_definition(local, authored)
except ValueError as exc:
raise ValueError(f"cron job {authored.get('name') or local.get('id')!r}: {exc}") from exc