mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(cron): CronScheduler ABC + InProcessCronScheduler (provider #1)
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.
Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
Required surface is name + start; is_available()/stop() carry safe defaults.
is_available has a no-network invariant. Docstring marks it experimental
until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
stop_event.wait(interval) for responsive stop (both raw tickers already do).
Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).
tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
This commit is contained in:
parent
a657397769
commit
e6ff41ca95
2 changed files with 176 additions and 0 deletions
|
|
@ -81,3 +81,81 @@ def test_desktop_ticker_calls_tick_then_stops():
|
|||
assert not t.is_alive(), "desktop ticker did not exit after stop_event was set"
|
||||
assert len(calls) >= 1, "desktop ticker never called tick()"
|
||||
assert calls[0].get("sync") is False
|
||||
|
||||
|
||||
# ── Phase 1: CronScheduler ABC + InProcessCronScheduler ──────────────────────
|
||||
|
||||
|
||||
def test_cronscheduler_is_abstract():
|
||||
"""name + start are abstract — the bare ABC can't be instantiated."""
|
||||
import pytest
|
||||
from cron.scheduler_provider import CronScheduler
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
CronScheduler()
|
||||
|
||||
|
||||
def test_cronscheduler_default_is_available_true():
|
||||
"""is_available defaults to True (no-network) for a minimal subclass."""
|
||||
from cron.scheduler_provider import CronScheduler
|
||||
|
||||
class Dummy(CronScheduler):
|
||||
@property
|
||||
def name(self):
|
||||
return "dummy"
|
||||
|
||||
def start(self, stop_event, **kw):
|
||||
pass
|
||||
|
||||
assert Dummy().is_available() is True
|
||||
|
||||
|
||||
def test_abc_growth_stays_additive():
|
||||
"""Forward-compat guard: the ABC's REQUIRED surface is exactly name+start.
|
||||
|
||||
Any optional hook added later for the external provider
|
||||
(on_jobs_changed/fire_due/reconcile) must be NON-abstract (carry a default),
|
||||
so the built-in keeps satisfying the ABC without overriding them. This test
|
||||
fails loudly if someone makes a future hook abstract (a breaking change that
|
||||
would force every provider — including the built-in — to implement it).
|
||||
"""
|
||||
from cron.scheduler_provider import CronScheduler
|
||||
|
||||
abstract = set(getattr(CronScheduler, "__abstractmethods__", set()))
|
||||
assert abstract == {"name", "start"}, (
|
||||
f"CronScheduler abstractmethods changed to {abstract}; growth must be "
|
||||
"additive (optional methods with defaults), not new abstract methods."
|
||||
)
|
||||
|
||||
|
||||
def test_inprocess_provider_ticks_and_stops():
|
||||
"""The built-in provider drives cron.scheduler.tick(sync=False) on a loop
|
||||
and exits promptly when stop_event is set — same contract as the raw
|
||||
ticker characterized above."""
|
||||
from cron.scheduler_provider import InProcessCronScheduler
|
||||
|
||||
calls = []
|
||||
stop = threading.Event()
|
||||
prov = InProcessCronScheduler()
|
||||
assert prov.name == "builtin"
|
||||
|
||||
with patch("cron.scheduler.tick", side_effect=lambda *a, **k: calls.append(k) or 0):
|
||||
t = threading.Thread(
|
||||
target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True
|
||||
)
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not t.is_alive(), "provider did not exit after stop_event was set"
|
||||
assert len(calls) >= 1, "provider never called tick()"
|
||||
assert calls[0].get("sync") is False
|
||||
|
||||
|
||||
def test_inprocess_provider_stop_is_noop():
|
||||
"""The default stop() hook is a safe no-op (the stop_event is the real
|
||||
stop signal for the built-in)."""
|
||||
from cron.scheduler_provider import InProcessCronScheduler
|
||||
|
||||
assert InProcessCronScheduler().stop() is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue