From b81c05581f605c8dfbc370a984f463598b2cadf4 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:02:53 -0700 Subject: [PATCH] fix(cron): coerce string schedule/state ms fields from jobs.json jobs.json can store everyMs/atMs and next/last run timestamps as strings. Loading left them as str, so _compute_next_run compared str to int and raised TypeError. Coerce with an optional-int helper at from_store_dict, matching runHistory int() paths. --- nanobot/cron/types.py | 14 +++++++----- tests/cron/test_cron_service.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/nanobot/cron/types.py b/nanobot/cron/types.py index 563cd0c2..18700765 100644 --- a/nanobot/cron/types.py +++ b/nanobot/cron/types.py @@ -8,7 +8,7 @@ from typing import Any, Literal from nanobot.utils.dict_keys import get_camel_snake -def _store_int(value: Any, default: int = 0) -> int: +def _store_int(value: Any, default: int | None = 0) -> int | None: """Coerce JSON numerics to int; treat null/blank like a missing key.""" if value is None or value == "": return default @@ -32,8 +32,8 @@ class CronSchedule: def from_store_dict(cls, data: dict[str, Any]) -> CronSchedule: return cls( kind=data["kind"], - at_ms=get_camel_snake(data, "atMs", "at_ms"), - every_ms=get_camel_snake(data, "everyMs", "every_ms"), + at_ms=_store_int(get_camel_snake(data, "atMs", "at_ms"), None), + every_ms=_store_int(get_camel_snake(data, "everyMs", "every_ms"), None), expr=data.get("expr"), tz=data.get("tz"), ) @@ -105,8 +105,12 @@ class CronJobState: def from_store_dict(cls, data: dict[str, Any]) -> CronJobState: history = get_camel_snake(data, "runHistory", "run_history", []) or [] return cls( - next_run_at_ms=get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"), - last_run_at_ms=get_camel_snake(data, "lastRunAtMs", "last_run_at_ms"), + next_run_at_ms=_store_int( + get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"), None + ), + last_run_at_ms=_store_int( + get_camel_snake(data, "lastRunAtMs", "last_run_at_ms"), None + ), last_status=get_camel_snake(data, "lastStatus", "last_status"), last_error=get_camel_snake(data, "lastError", "last_error"), run_history=[ diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 8ef534e1..772884bd 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -65,6 +65,44 @@ def test_load_jobs_accepts_snake_case_schedule_and_run_history(tmp_path) -> None assert jobs[0].state.run_history[0].duration_ms == 12 +def test_load_jobs_coerces_string_schedule_and_state_ms(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text( + json.dumps( + { + "version": 1, + "jobs": [ + { + "id": "j1", + "name": "t", + "enabled": True, + "schedule": {"kind": "every", "everyMs": "60000"}, + "payload": { + "kind": "agent_turn", + "message": "hi", + "sessionKey": "websocket:chat-1", + }, + "state": { + "nextRunAtMs": "100", + "lastRunAtMs": "50", + }, + "createdAtMs": 0, + "updatedAtMs": 0, + } + ], + } + ), + encoding="utf-8", + ) + + jobs, _version = CronService(store_path)._load_jobs() + assert jobs is not None + assert jobs[0].schedule.every_ms == 60_000 + assert jobs[0].state.next_run_at_ms == 100 + assert jobs[0].state.last_run_at_ms == 50 + + def test_add_job_rejects_unknown_timezone(tmp_path) -> None: service = CronService(tmp_path / "cron" / "jobs.json")