From be68ba708c4d79af203fde88a7066bf45ff9f78c Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Mon, 6 Apr 2026 13:54:57 -0600 Subject: [PATCH] Add next_run, duration_seconds, and max_age_seconds to status events Enrich supervisor.status with next_run (epoch ms) on each schedule entry, and observe.status with running.duration_seconds and per-handler max_age_seconds for the health dashboard. --- observe/sense.py | 10 ++++++++ tests/test_scheduler.py | 51 +++++++++++++++++++++++++++++++++++++++++ think/scheduler.py | 21 +++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/observe/sense.py b/observe/sense.py index 1fdf460e0..48dd3c5ce 100644 --- a/observe/sense.py +++ b/observe/sense.py @@ -685,6 +685,9 @@ class FileSensor: "file": rel_file, "ref": handler_proc.managed.ref, } + handler_status["running"]["duration_seconds"] = int( + now - handler_proc.started_at + ) # Queued items with age if handler_queue.queue_size() > 0: @@ -700,6 +703,13 @@ class FileSensor: ) handler_status["queued"] = queued_list + if handler_queue.queue_size() > 0: + handler_status["max_age_seconds"] = int( + now - min(item.queued_at for item in handler_queue.queue) + ) + elif handler_status: + handler_status["max_age_seconds"] = 0 + # Add section if any activity for this handler if handler_status: status[handler_name] = handler_status diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 6b5c4d488..75fc17ecf 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -950,6 +950,57 @@ class TestCollectStatus: assert "last_run" in status[0] assert "due" in status[0] + def test_next_run_hourly(self, journal_path): + import think.scheduler as mod + + mod._entries = {"a": {"cmd": ["sol", "x"], "every": "hourly"}} + mod._state = {"a": {"last_run": datetime(2026, 2, 17, 14, 5).timestamp()}} + + with _fake_now(datetime(2026, 2, 17, 14, 30)): + status = mod.collect_status() + + expected = int(datetime(2026, 2, 17, 15, 0).timestamp() * 1000) + assert status[0]["next_run"] == expected + + def test_next_run_daily(self, journal_path): + import think.scheduler as mod + + mod._daily_time = "03:00" + mod._entries = {"a": {"cmd": ["sol", "x"], "every": "daily"}} + mod._state = {"a": {"last_run": datetime(2026, 2, 17, 3, 30).timestamp()}} + + with _fake_now(datetime(2026, 2, 17, 4, 0)): + status = mod.collect_status() + + expected = int(datetime(2026, 2, 18, 3, 0).timestamp() * 1000) + assert status[0]["next_run"] == expected + + def test_next_run_weekly(self, journal_path): + import think.scheduler as mod + + mod._weekly_day = "sunday" + mod._weekly_time = "03:00" + mod._entries = {"a": {"cmd": ["sol", "x"], "every": "weekly"}} + mod._state = {"a": {"last_run": datetime(2026, 3, 22, 3, 30).timestamp()}} + + with _fake_now(datetime(2026, 3, 22, 4, 0)): + status = mod.collect_status() + + expected = int(datetime(2026, 3, 29, 3, 0).timestamp() * 1000) + assert status[0]["next_run"] == expected + + def test_next_run_when_due(self, journal_path): + import think.scheduler as mod + + mod._entries = {"a": {"cmd": ["sol", "x"], "every": "hourly"}} + mod._state = {} + + with _fake_now(datetime(2026, 2, 17, 14, 30)): + status = mod.collect_status() + + expected = int(datetime(2026, 2, 17, 14, 0).timestamp() * 1000) + assert status[0]["next_run"] == expected + class TestHeartbeatSchedule: """Tests for heartbeat schedule registration and daily firing.""" diff --git a/think/scheduler.py b/think/scheduler.py index cf4ec48e9..ced565807 100644 --- a/think/scheduler.py +++ b/think/scheduler.py @@ -473,6 +473,7 @@ def collect_status() -> list[dict[str, Any]]: "last_run": last_run, "due": _is_due(entry, state_entry, now), } + entry_status["next_run"] = _compute_next_run(entry, state_entry, now) if entry["every"] == "daily" and _daily_time: entry_status["daily_time"] = _daily_time if entry["every"] == "weekly": @@ -484,6 +485,26 @@ def collect_status() -> list[dict[str, Any]]: return result +def _compute_next_run(entry: dict, state_entry: dict | None, now: datetime) -> int: + """Compute next run time as epoch milliseconds.""" + every = entry["every"] + if every == "hourly": + mark = _hour_mark(now) + nxt = mark if _is_due(entry, state_entry, now) else mark + timedelta(hours=1) + elif every == "daily": + mark = _compute_daily_mark(now, _daily_time) + nxt = mark if _is_due(entry, state_entry, now) else mark + timedelta(days=1) + elif every == "weekly": + weekly_day_val = _parse_weekly_day(_weekly_day) + if weekly_day_val is None: + weekly_day_val = 6 + mark = _compute_weekly_mark(now, weekly_day_val, _weekly_time) + nxt = mark if _is_due(entry, state_entry, now) else mark + timedelta(weeks=1) + else: + return int(now.timestamp() * 1000) + return int(nxt.timestamp() * 1000) + + # --------------------------------------------------------------------------- # CLI: sol schedule # --------------------------------------------------------------------------- -- 2.51.2