"""Stale-code cron tick yield gate. A long-lived process whose checkout was updated underneath it (hot git pull / interrupted ``hermes update``) serves mixed ``sys.modules``; when such a process races a fresh gateway for the cron tick lock and wins, every agent job it dispatches can die on ImportErrors whose real cause is staleness. The gate in ``cron.scheduler.tick`` (before lock acquisition) yields — raises ``CronTickYielded`` — only when ALL of: 1. this process is provably stale (boot fingerprint ≠ disk revision), 2. it does NOT own the gateway runtime lock, and 3. some other process currently holds that lock. Any probe failure or missing fingerprint means "proceed" (fail-open). Yielded ticks must surface as failed ticks (``record_ticker_error`` + heartbeat ``success=False``), never as healthy ones. """ from __future__ import annotations import logging import threading import time from unittest.mock import patch import pytest import cron.scheduler as scheduler_mod SKEW = ("boot0123abcd", "disk4567efgh") def _wait_until(predicate, timeout=10.0, interval=0.005): """Block until ``predicate()`` is truthy or ``timeout`` elapses.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: value = predicate() if value: return value time.sleep(interval) return predicate() def _gate_mocks(monkeypatch, *, owns: bool, active: bool, skew=SKEW): """Point the yield gate's three probes at fixed answers.""" from gateway import status as gateway_status monkeypatch.setattr(scheduler_mod, "_detect_gateway_code_skew", lambda: skew) monkeypatch.setattr(gateway_status, "owns_gateway_runtime_lock", lambda: owns) monkeypatch.setattr( gateway_status, "is_gateway_runtime_lock_active", lambda lock_path=None: active ) class TestTickYieldGate: def test_skew_plus_foreign_gateway_yields_before_lock(self, monkeypatch, tmp_path): """(a) skew + a fresh foreign gateway holds the runtime lock → the tick yields WITHOUT acquiring the tick lock or dispatching.""" _gate_mocks(monkeypatch, owns=False, active=True) def _no_lock_allowed(*_a, **_kw): raise AssertionError("tick lock must not be acquired after a yield") monkeypatch.setattr(scheduler_mod, "_get_lock_paths", _no_lock_allowed) with pytest.raises(scheduler_mod.CronTickYielded) as excinfo: scheduler_mod.tick(verbose=False) # The exception carries the forensics for the ticker-error record. assert SKEW[0] in str(excinfo.value) assert SKEW[1] in str(excinfo.value) def test_skew_plus_self_owned_lock_proceeds(self, monkeypatch, tmp_path): """(b) skew + this process IS the gateway → proceed. The delivery path's stale-code hint (part 3) stays the surface for this case.""" _gate_mocks(monkeypatch, owns=True, active=True) # Reaching the lock paths proves the gate let the tick proceed. called = [] real = scheduler_mod._get_lock_paths def _spy(): called.append(1) return real() monkeypatch.setattr(scheduler_mod, "_get_lock_paths", _spy) assert scheduler_mod.tick(verbose=False) == 0 # empty store: no jobs assert called, "tick must proceed when this process owns the gateway lock" def test_skew_plus_no_gateway_proceeds(self, monkeypatch, tmp_path): """(c) skew + no gateway alive (desktop-standalone) → proceed: yielding would silently kill the user's only ticker.""" _gate_mocks(monkeypatch, owns=False, active=False) assert scheduler_mod.tick(verbose=False) == 0 def test_no_fingerprint_proceeds(self, monkeypatch, tmp_path): """(d) skew is None (non-git install, no boot fingerprint, probe failure) → always proceed.""" _gate_mocks(monkeypatch, owns=False, active=True, skew=None) assert scheduler_mod.tick(verbose=False) == 0 def test_lock_probe_exception_proceeds(self, monkeypatch, tmp_path): """Fail-open: the lock probe raising must not yield or crash the tick.""" from gateway import status as gateway_status monkeypatch.setattr(scheduler_mod, "_detect_gateway_code_skew", lambda: SKEW) def _boom(lock_path=None): raise OSError("lock probe failed") monkeypatch.setattr(gateway_status, "is_gateway_runtime_lock_active", _boom) assert scheduler_mod.tick(verbose=False) == 0 def test_yield_logs_once_per_episode(self, monkeypatch, caplog): """The yield log is throttled: repeated yields with the same skew signature log once, not once per tick interval.""" _gate_mocks(monkeypatch, owns=False, active=True) with caplog.at_level(logging.ERROR, logger="cron.scheduler"): scheduler_mod._log_tick_yield_once("boot=a disk=b") scheduler_mod._log_tick_yield_once("boot=a disk=b") scheduler_mod._log_tick_yield_once("boot=a disk=b") yield_logs = [ r for r in caplog.records if "Cron tick yielded" in r.getMessage() ] assert len(yield_logs) == 1 # A NEW skew signature (the checkout moved again) is a new episode. with caplog.at_level(logging.ERROR, logger="cron.scheduler"): scheduler_mod._log_tick_yield_once("boot=a disk=c") yield_logs = [ r for r in caplog.records if "Cron tick yielded" in r.getMessage() ] assert len(yield_logs) == 2 class TestYieldedTickIsAFailedTick: """(e) A yielded tick must not be recorded as a healthy tick.""" def test_provider_records_error_and_unsuccessful_heartbeat(self): from cron.scheduler_provider import InProcessCronScheduler beats: list[bool] = [] errors: list[str] = [] stop = threading.Event() prov = InProcessCronScheduler() with patch( "cron.scheduler.tick", side_effect=scheduler_mod.CronTickYielded(SKEW[0], SKEW[1]), ), patch( "cron.jobs.record_ticker_heartbeat", side_effect=lambda success=False: beats.append(success), ), patch( "cron.jobs.record_ticker_error", side_effect=lambda msg: errors.append(msg), ), patch("cron.jobs.clear_ticker_error") as clear: t = threading.Thread( target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True ) t.start() assert _wait_until(lambda: len(beats) >= 3), "ticker did not keep beating" stop.set() t.join(timeout=5) assert not t.is_alive(), "ticker must keep yielding, not die" assert errors, "yield reason must be persisted for `hermes cron status`" assert "CronTickYielded" in errors[0] or "yielded" in errors[0] assert beats[-1] is False, "a yielded tick is not a successful tick" clear.assert_not_called() def test_ticker_takes_over_when_gateway_lock_releases(self): """Self-healing: once the fresh gateway dies (lock inactive), the yielding ticker's next tick proceeds normally.""" from cron.scheduler_provider import InProcessCronScheduler beats: list[bool] = [] state = {"foreign_gateway_alive": True} stop = threading.Event() prov = InProcessCronScheduler() def _tick(*args, **kwargs): # Gate logic is exercised for real; emulate the two probe arms. if state["foreign_gateway_alive"]: raise scheduler_mod.CronTickYielded(SKEW[0], SKEW[1]) return 0 with patch("cron.scheduler.tick", side_effect=_tick), patch( "cron.jobs.record_ticker_heartbeat", side_effect=lambda success=False: beats.append(success), ), patch("cron.jobs.record_ticker_error"), patch("cron.jobs.clear_ticker_error"): t = threading.Thread( target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True ) t.start() assert _wait_until(lambda: len(beats) >= 2) state["foreign_gateway_alive"] = False # gateway lock released assert _wait_until(lambda: True in beats), "ticker never took over" stop.set() t.join(timeout=5) assert not t.is_alive() def test_multiplex_yield_in_one_profile_does_not_cancel_siblings(self, tmp_path): """Multiplex loop: a yield for profile A (its fresh gateway owns the runtime lock) must not cancel profile B's tick in the same cycle — B may have no other ticker. B's heartbeat stays healthy; A's records the yield.""" from cron.scheduler_provider import InProcessCronScheduler # The multiplex loop skips profile homes that don't exist on disk # (_existing_profile_homes, #47368) — use real directories. home_a = str(tmp_path / "home-a") home_b = str(tmp_path / "home-b") (tmp_path / "home-a").mkdir() (tmp_path / "home-b").mkdir() ticked: list[str] = [] per_home_beats: dict[str, list[bool]] = {home_a: [], home_b: []} per_home_errors: dict[str, list[str]] = {home_a: [], home_b: []} state = {"cycles": 0} stop = threading.Event() prov = InProcessCronScheduler() def _tick(*args, **kwargs): from hermes_constants import get_hermes_home home = str(get_hermes_home()) ticked.append(home) if home == home_a: # Profile A: stale process, fresh foreign gateway → yield. raise scheduler_mod.CronTickYielded(SKEW[0], SKEW[1]) # Profile B ticks fine. return 0 def _beat(success=False): from hermes_constants import get_hermes_home per_home_beats[str(get_hermes_home())].append(success) def _err(msg): from hermes_constants import get_hermes_home per_home_errors[str(get_hermes_home())].append(msg) with patch("cron.scheduler.tick", side_effect=_tick), patch( "cron.jobs.record_ticker_heartbeat", side_effect=_beat ), patch("cron.jobs.record_ticker_error", side_effect=_err), patch( "cron.jobs.clear_ticker_error" ): t = threading.Thread( target=prov.start, args=(stop,), kwargs={ "interval": 0, "profile_homes": [home_a, home_b], }, daemon=True, ) t.start() assert _wait_until(lambda: len(ticked) >= 6), "multiplex loop did not run" stop.set() t.join(timeout=5) assert not t.is_alive() # Every cycle ticked BOTH homes — B was never cancelled by A's yield. assert home_b in ticked and home_a in ticked # A recorded unsuccessful beats + its yield reason; B stayed healthy. # (The first beat per home is the pre-loop initial heartbeat, always # unsuccessful — skip it when judging cycle outcomes.) assert per_home_beats[home_a], "profile A must still beat (liveness)" assert all(b is False for b in per_home_beats[home_a][1:]) assert per_home_errors[home_a], "profile A must record the yield reason" assert per_home_beats[home_b][1:] and all( b is True for b in per_home_beats[home_b][1:] ), "profile B must stay healthy" assert not per_home_errors[home_b], "profile B must not inherit A's yield" class TestGatewayLockOwnershipProbe: """The self-ownership accessor backing the gate, against the real lock.""" def test_ownership_follows_acquire_and_release(self, tmp_path, monkeypatch): from gateway import status as gateway_status monkeypatch.setenv("HERMES_HOME", str(tmp_path)) assert gateway_status.owns_gateway_runtime_lock() is False assert gateway_status.acquire_gateway_runtime_lock() is True try: assert gateway_status.owns_gateway_runtime_lock() is True # Self-ownership is distinguishable even though the shared # liveness probe also reports active for the owner. assert gateway_status.is_gateway_runtime_lock_active() is True finally: gateway_status.release_gateway_runtime_lock() assert gateway_status.owns_gateway_runtime_lock() is False def test_ownership_probe_is_profile_scoped_by_process_home( self, tmp_path, monkeypatch ): """The gateway lock is a process-level identity file (#56986): under a multiplex profile-home override the probe must still resolve the launch home's lock, not the overridden profile's.""" from gateway import status as gateway_status from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) other_profile = tmp_path / "profiles" / "other" other_profile.mkdir(parents=True) token = set_hermes_home_override(str(other_profile)) try: assert gateway_status.acquire_gateway_runtime_lock() is True try: assert gateway_status.owns_gateway_runtime_lock() is True # The lock file lives in the launch home, not the override. assert (tmp_path / "gateway.lock").exists() assert not (other_profile / "gateway.lock").exists() finally: gateway_status.release_gateway_runtime_lock() finally: reset_hermes_home_override(token)