Files
hermes-agent/tests/gateway/test_cron_ticks_every_profile.py
teknium1 cb647a018f fix(cron): one host ticker owns every profile's cron, per profile
The cron ticker multiplexes N profiles from one process while its ownership
predicates and its in-flight bookkeeping still assumed one profile per process.

- `_should_yield_tick_to_fresh_gateway` asked a process-global boolean
  (`owns_gateway_runtime_lock`) and a launch-home lock probe, so one answer
  covered every profile ticked. It now asks `scheduler_ownership`:
  `owns_cron_tick_for(home)` (this process is the host gateway AND ticks that
  home) and `live_gateway_ticking(home)` (another live host gateway whose
  published served set covers that home).
- In-flight state (`_running_job_ids`, `_running_since`, `_running_futures`,
  `_running_allowance_s`, `_running_worker_pids`, `_running_fire_owners`,
  `_restart_safe_waiter_job_ids`, `_interrupted_job_ids`) is keyed by
  `_inflight_key(job_id)` = `(home key, job id)`; two profiles carrying a
  `daily-brief` no longer read as one job. The public accessors still report
  the host-wide union of bare job ids for the shutdown drain.
- The parallel worker pool is keyed by home: `cron.max_parallel_jobs` is a
  per-profile key, and the single global pool was sized by whichever profile
  ticked first and torn down by the next one.
- `gateway/run.py` no longer gates the cron tick set on
  `gateway.multiplex_profiles`: that flag gates adapters, and with it off every
  non-launch profile's jobs sat in a store no ticker visited.
2026-09-21 02:52:15 -07:00

87 lines
2.9 KiB
Python

"""The host gateway's cron ticker visits every profile, flag or no flag.
``gateway.multiplex_profiles`` gates ADAPTERS. Cron ownership is not optional: one gateway process
per host ticks every profile's store, so a secondary profile's due job fires even when the flag is
off. Before this, ``_start_gateway_start_cron_and_housekeeping`` only passed ``profile_homes`` when
the flag was on and every non-launch profile's cron silently never fired.
"""
from __future__ import annotations
import asyncio
import threading
import types
import pytest
class _CapturedTicker:
"""Stands in for ``SupervisedTickerThread``; records the ticker's start kwargs."""
captured: dict = {}
def __init__(self, target, *, args=(), kwargs=None, stop_event=None, name="cron"):
_CapturedTicker.captured = dict(kwargs or {})
self.restarts = 0
def start(self):
return None
def restart_if_dead(self):
return None
def is_alive(self):
return False
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch):
"""A scratch HOME so profile enumeration can never read or write the live install."""
fake_home = tmp_path / "fakehome"
hermes_home = fake_home / ".hermes"
(hermes_home / "profiles" / "secondary").mkdir(parents=True)
# A dir is only a profile once it carries an identity marker.
(hermes_home / "profiles" / "secondary" / "config.yaml").write_text("{}\n")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setenv("USERPROFILE", str(fake_home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_cli import profiles as profiles_mod
assert str(profiles_mod._get_profiles_root()).startswith(str(tmp_path))
return hermes_home
def test_secondary_profile_is_ticked_with_multiplex_profiles_off(
isolated_profiles, monkeypatch
):
from cron import scheduler_thread
from gateway import run as gateway_run
monkeypatch.setattr(scheduler_thread, "SupervisedTickerThread", _CapturedTicker)
monkeypatch.setattr(
gateway_run, "_start_gateway_housekeeping", lambda *_a, **_kw: None)
runner = types.SimpleNamespace(
config=types.SimpleNamespace(multiplex_profiles=False),
adapters={},
_profile_adapters=None,
_primary_profile_name="default",
_draining=False,
_external_drain_active=False,
)
async def _start():
return gateway_run._start_gateway_start_cron_and_housekeeping(runner)
cron_stop, _provider, _thread, housekeeping = asyncio.run(_start())
cron_stop.set()
if isinstance(housekeeping, threading.Thread):
housekeeping.join(timeout=5)
enumerate_homes = _CapturedTicker.captured.get("profile_homes")
assert enumerate_homes is not None, (
"the ticker must be given the profile set even with multiplex_profiles off"
)
served = {name for name, _home in enumerate_homes()}
assert "secondary" in served, "a secondary profile's cron must still be ticked"