Files
hermes-agent/tests/cron/test_cron_ticker_drift.py
teknium1 7ae85d89a6 test(cron): trim ticker-drift tests to two loop-agnostic invariants
Replace the five per-loop snapshot tests from #114473 with two contracts,
each parametrized over the single-profile and multiplex loops:

- scheduled instants stay at k * interval (error does not grow with k)
- a tick that overruns the interval re-baselines instead of bursting

Why: the salvage bar is 1-2 invariant tests; exact per-cycle wait snapshots
(59.8 / 59.5 / 59.9) pin the fixture, not the contract. The clock seam is
the module's own `time` binding (monkeypatch `sp.time`), so the test cannot
leak a patched `time.monotonic` into the rest of the process.
2026-09-18 09:17:29 -07:00

76 lines
2.7 KiB
Python

"""Ticker loop period contract: the sleep is to a deadline, not for a duration.
Both ticker loops (single-profile and multiplex) must schedule tick k at
``start + k * interval`` regardless of how long each tick took, and a tick that
overruns the interval must re-baseline rather than fire a burst of zero-length waits.
Behaviour contract per ``cron/AGENTS.md``: loose bounds on a virtual clock, no sleeping.
"""
from unittest.mock import patch
import pytest
import cron.scheduler_provider as sp
INTERVAL = 60.0
class _FakeTime:
now = 100.0
@classmethod
def monotonic(cls):
return cls.now
class _RecordingStop:
def __init__(self, cycles):
self.waits = []
self._cycles = cycles
def is_set(self):
return len(self.waits) >= self._cycles
def wait(self, timeout=None):
self.waits.append(timeout)
_FakeTime.now += timeout
return self.is_set()
def _drive(loop, tmp_path, monkeypatch, *, tick_cost, cycles):
"""Run the real ticker loop on a virtual clock; return (waits, elapsed virtual seconds)."""
_FakeTime.now = 100.0
monkeypatch.setattr(sp, "time", _FakeTime)
stop = _RecordingStop(cycles)
provider = sp.InProcessCronScheduler()
def fake_tick(*args, **kwargs):
_FakeTime.now += tick_cost
return 0
(tmp_path / "cron").mkdir(exist_ok=True)
profile_homes = None if loop == "single" else [("p1", tmp_path)]
with patch.object(provider, "recover_interrupted", return_value=0), \
patch("cron.scheduler.tick", side_effect=fake_tick), \
patch("cron.jobs.record_ticker_heartbeat"), \
patch("cron.jobs.clear_ticker_error"):
provider.start(stop, interval=INTERVAL, profile_homes=profile_homes)
return stop.waits, _FakeTime.now - 100.0
@pytest.mark.parametrize("loop", ["single", "multiplex"])
def test_ticker_period_does_not_accumulate_tick_cost(loop, tmp_path, monkeypatch):
"""Scheduled instants stay at k * interval: error must not grow with k."""
cycles = 20
waits, elapsed = _drive(loop, tmp_path, monkeypatch, tick_cost=0.25, cycles=cycles)
assert len(waits) == cycles
assert all(0 < w < INTERVAL for w in waits), waits
# Sleep-after-work would put this at cycles * (interval + 0.25) = +5s of drift.
assert abs(elapsed - cycles * INTERVAL) < 0.1
@pytest.mark.parametrize("loop", ["single", "multiplex"])
def test_ticker_overrun_rebaselines_instead_of_bursting(loop, tmp_path, monkeypatch):
"""A tick longer than the interval is followed by normal waits, not zero-length ones."""
waits, _ = _drive(loop, tmp_path, monkeypatch, tick_cost=1.7 * INTERVAL, cycles=3)
assert all(w == pytest.approx(INTERVAL) for w in waits), waits