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.
87 lines
2.9 KiB
Python
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"
|