Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""Missed-run dispatch visibility (#99879).
|
||||
|
||||
The catch-up machinery already ran late fires after gateway downtime, but
|
||||
the run then looked like an ordinary on-time success: nothing recorded the
|
||||
scheduled instant vs the actual dispatch time. The due-scan now persists a
|
||||
``last_dispatch`` stamp (scheduled_at / dispatched_at / lateness_seconds /
|
||||
kind) on every recurring dispatch so `hermes cron list` and
|
||||
`hermes cron status` can surface late catch-ups.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from cron.jobs import (
|
||||
_classify_dispatch_lateness,
|
||||
get_due_jobs,
|
||||
load_jobs,
|
||||
save_jobs,
|
||||
)
|
||||
|
||||
FIXED_NOW = datetime(2026, 9, 1, 9, 31, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cron_store(tmp_path, monkeypatch):
|
||||
"""Redirect cron storage to a temp dir and pin the clock."""
|
||||
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
||||
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
||||
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: FIXED_NOW)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _daily_job(jid, next_run_dt, **extra):
|
||||
job = {
|
||||
"id": jid,
|
||||
"name": jid,
|
||||
"prompt": "x",
|
||||
"schedule": {"kind": "cron", "expr": "0 9 * * *"},
|
||||
"next_run_at": next_run_dt.isoformat(),
|
||||
"last_run_at": None,
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"repeat": {"times": None, "completed": 0},
|
||||
"deliver": "local",
|
||||
}
|
||||
job.update(extra)
|
||||
return job
|
||||
|
||||
|
||||
def _interval_job(jid, next_run_dt, **extra):
|
||||
job = {
|
||||
"id": jid,
|
||||
"name": jid,
|
||||
"prompt": "x",
|
||||
"schedule": {"kind": "interval", "minutes": 60},
|
||||
"next_run_at": next_run_dt.isoformat(),
|
||||
"last_run_at": None,
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"repeat": {"times": None, "completed": 0},
|
||||
"deliver": "local",
|
||||
}
|
||||
job.update(extra)
|
||||
return job
|
||||
|
||||
|
||||
class TestDueScanDispatchStamp:
|
||||
def test_catch_up_beyond_grace_stamped_and_persisted(self, cron_store):
|
||||
# Scheduled yesterday 09:00, now is today 09:31 — far beyond the
|
||||
# 2h max grace for a daily job: the catch-up path fires once now.
|
||||
scheduled = FIXED_NOW - timedelta(hours=24, minutes=31)
|
||||
save_jobs([_daily_job("daily", scheduled)])
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
assert [d["id"] for d in due] == ["daily"]
|
||||
stamp = due[0]["last_dispatch"]
|
||||
assert stamp["kind"] == "catch_up"
|
||||
assert stamp["scheduled_at"] == scheduled.isoformat()
|
||||
assert stamp["dispatched_at"] == FIXED_NOW.isoformat()
|
||||
expected_late = (FIXED_NOW - scheduled).total_seconds()
|
||||
assert stamp["lateness_seconds"] == pytest.approx(expected_late, abs=1)
|
||||
# Persisted, so a separate `hermes cron list` process can read it.
|
||||
persisted = load_jobs()[0]
|
||||
assert persisted["last_dispatch"] == stamp
|
||||
|
||||
def test_late_within_grace_stamped_late(self, cron_store):
|
||||
# 31 minutes late: within the daily 2h grace window but beyond the
|
||||
# on-time ticker tolerance — the shape from issue #99879's report.
|
||||
scheduled = FIXED_NOW - timedelta(minutes=31)
|
||||
save_jobs([_daily_job("daily", scheduled)])
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
stamp = due[0]["last_dispatch"]
|
||||
assert stamp["kind"] == "late"
|
||||
assert stamp["lateness_seconds"] == pytest.approx(31 * 60, abs=1)
|
||||
|
||||
def test_on_time_dispatch_stamped_on_time(self, cron_store):
|
||||
# Interval schedule: no cron-expr matching guard, dispatch 30s late
|
||||
# is normal ticker cadence.
|
||||
scheduled = FIXED_NOW - timedelta(seconds=30)
|
||||
save_jobs([_interval_job("hourly", scheduled)])
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
stamp = due[0]["last_dispatch"]
|
||||
assert stamp["kind"] == "on_time"
|
||||
assert stamp["lateness_seconds"] == pytest.approx(30, abs=1)
|
||||
|
||||
def test_manual_trigger_not_stamped(self, cron_store):
|
||||
# A manual trigger stamps manual_run_at == next_run_at; there is no
|
||||
# scheduled instant to be late against, so no dispatch stamp.
|
||||
run_at = FIXED_NOW - timedelta(hours=5)
|
||||
save_jobs([
|
||||
_daily_job("manual", run_at, manual_run_at=run_at.isoformat())
|
||||
])
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
assert [d["id"] for d in due] == ["manual"]
|
||||
assert "last_dispatch" not in due[0]
|
||||
|
||||
def test_new_dispatch_overwrites_stale_stamp(self, cron_store):
|
||||
scheduled = FIXED_NOW - timedelta(seconds=10)
|
||||
stale = {
|
||||
"scheduled_at": "2026-08-30T09:00:00+00:00",
|
||||
"dispatched_at": "2026-08-30T11:00:00+00:00",
|
||||
"lateness_seconds": 7200.0,
|
||||
"kind": "catch_up",
|
||||
}
|
||||
save_jobs([_interval_job("hourly", scheduled, last_dispatch=stale)])
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
stamp = due[0]["last_dispatch"]
|
||||
assert stamp["kind"] == "on_time"
|
||||
assert stamp["scheduled_at"] == scheduled.isoformat()
|
||||
|
||||
|
||||
class TestClassifyDispatchLateness:
|
||||
def test_within_tolerance_is_on_time(self):
|
||||
assert _classify_dispatch_lateness(0, 7200) == "on_time"
|
||||
assert _classify_dispatch_lateness(299, 7200) == "on_time"
|
||||
|
||||
def test_beyond_tolerance_within_grace_is_late(self):
|
||||
assert _classify_dispatch_lateness(301, 7200) == "late"
|
||||
assert _classify_dispatch_lateness(7200, 7200) == "late"
|
||||
|
||||
def test_beyond_grace_is_catch_up(self):
|
||||
assert _classify_dispatch_lateness(7201, 7200) == "catch_up"
|
||||
Reference in New Issue
Block a user