Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
943f88102d fix: cron scheduler crash on string schedule formats
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 33s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 7s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 58s
Docs Site Checks / docs-site-checks (pull_request) Failing after 2m50s
Tests / e2e (pull_request) Successful in 2m48s
Tests / test (pull_request) Failing after 47m25s
Nix / nix (macos-latest) (pull_request) Has been cancelled
The scheduler crashed with AttributeError: 'str' object has no
attribute 'get' because jobs.json stores schedules as strings
(e.g. '*/3 * * * *', 'every 15m') but downstream functions expect
dicts with a 'kind' key.

Added _normalize_job_schedules() that runs on load_jobs() to parse
all string schedules into structured dicts. Also added defensive
isinstance checks at two call sites.

Fixes the frozen scheduler — all cron jobs were stuck since April 13.
2026-04-14 20:56:51 -04:00

View File

@@ -317,6 +317,21 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
# Job CRUD Operations
# =============================================================================
def _normalize_job_schedules(jobs: List[Dict[str, Any]]):
"""Convert any string schedules to parsed dicts in-place."""
for job in jobs:
sched = job.get("schedule")
if isinstance(sched, str):
try:
job["schedule"] = parse_schedule(sched)
except Exception:
job["schedule"] = {"kind": "unknown", "raw": sched}
elif isinstance(sched, dict):
pass # already fine
elif sched is None:
pass # no schedule (cron-managed or paused)
def load_jobs() -> List[Dict[str, Any]]:
"""Load all jobs from storage."""
ensure_dirs()
@@ -326,13 +341,16 @@ def load_jobs() -> List[Dict[str, Any]]:
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("jobs", [])
jobs = data.get("jobs", [])
_normalize_job_schedules(jobs)
return jobs
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
jobs = data.get("jobs", [])
_normalize_job_schedules(jobs)
if jobs:
# Auto-repair: rewrite with proper escaping
save_jobs(jobs)
@@ -642,7 +660,13 @@ def advance_next_run(job_id: str) -> bool:
jobs = load_jobs()
for job in jobs:
if job["id"] == job_id:
kind = job.get("schedule", {}).get("kind")
schedule = job.get("schedule", {})
if isinstance(schedule, str):
try:
schedule = parse_schedule(schedule)
except Exception:
return False
kind = schedule.get("kind")
if kind not in ("cron", "interval"):
return False
now = _hermes_now().isoformat()
@@ -699,6 +723,11 @@ def get_due_jobs() -> List[Dict[str, Any]]:
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
if next_run_dt <= now:
schedule = job.get("schedule", {})
if isinstance(schedule, str):
try:
schedule = parse_schedule(schedule)
except Exception:
schedule = {"kind": "unknown"}
kind = schedule.get("kind")
# For recurring jobs, check if the scheduled time is stale