Under gateway.multiplex_profiles a secondary profile X is ticked, dispatched
and notified from the default profile's process, where os.environ holds the
DEFAULT profile's .env and X's values live only in the per-turn secret scope /
HERMES_HOME override. Every remaining read that skipped that scope made X
behave differently from `hermes -p X gateway run`:
- cron: HERMES_CRON_TIMEOUT, HERMES_MODEL (job/preflight fallback),
HERMES_CRON_MAX_PARALLEL, inflight allowance, prefill file and the script
timeout were bare os.getenv → the default profile's values; a job without a
model silently ran on the default's HERMES_MODEL instead of refusing.
cron/env_settings.py::cron_env_setting reads the scope (fire) or the ticked
home's .env (tick thread), plain environ when multiplexing is off.
- child env: the restart-safe cron worker, the Bot Chat delivery child and the
kanban worker inherited the launch profile's non-credential .env settings
and bridged TERMINAL_* policy (TERMINAL_ENV=docker, default's image,
HERMES_MODEL) — X's worker ran in the default's docker image on the
default's model. tools/environments/local.py::strip_launch_profile_env drops
them when the child targets another served profile.
- kanban: the worker --toolsets pin was silently dropped for every served
assignee (toolset probes call get_secret without a scope → swallowed
UnscopedSecretError); notifier pings, artifact uploads and the wake text ran
under the default's media policy / display language (only wake() was scoped).
- /loop: _post_turn_loop_completion hopped to the executor without contextvars,
writing the completed tick into the DEFAULT profile's state.db and leaving
X's row awaiting_response forever; the --until judge ran with the default's
aux credentials.
- background processes: a secondary's processes.json (scope-relative since
adf23550f5) was never read at startup; its processes were not re-adopted
and notify_on_complete notices were lost. Startup recovers every served
home under its scope; recovery adopts each session once.
- completion delivery: background_process_notifications was evaluated once per
drain for the ambient profile (default's mode for everyone; X's `off`
dropped a sibling's `all` event), recovered watchers used the default's
mode, HERMES_BACKGROUND_NOTIFICATIONS was read raw from environ;
_deliver_platform_notice used the default's GatewayConfig so a secondary's
notice_delivery: private went public.
Not changed: gateway/run.py and tools/async_delegation.py (PR #106742
rewrites both). Known residue left for the env-bridge lane:
HERMES_SESSION_STALL_TIMEOUT is bridged once from the launch config.
scheduler bug, not a parity gap; unchanged here.
164 lines
7.5 KiB
Python
164 lines
7.5 KiB
Python
"""Standalone-vs-served parity for goals/loops, completion notices and process recovery under
|
|
``gateway.multiplex_profiles``: every read that decides FOR a served profile must run inside that
|
|
profile's runtime scope, and every store the served profile wrote must be recovered under it.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent.secret_scope import set_multiplex_active
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.run import GatewayRunner, _profile_runtime_scope
|
|
from gateway.session import SessionSource
|
|
from hermes_constants import get_hermes_home
|
|
|
|
|
|
class RecordingAdapter:
|
|
def __init__(self, notice_delivery=None):
|
|
self.config = PlatformConfig(enabled=True, extra={"notice_delivery": notice_delivery} if notice_delivery else {})
|
|
self.platform = Platform.TELEGRAM
|
|
self.calls = []
|
|
self._active_sessions, self._pending_messages, self._session_tasks = {}, {}, {}
|
|
|
|
async def send(self, chat_id, content, metadata=None, **kw):
|
|
self.calls.append(("send", str(get_hermes_home())))
|
|
return SimpleNamespace(success=True, error=None)
|
|
|
|
async def send_private_notice(self, chat_id, user_id, content, metadata=None, **kw):
|
|
self.calls.append(("send_private_notice", str(get_hermes_home())))
|
|
return SimpleNamespace(success=True, error=None)
|
|
|
|
async def handle_message(self, event):
|
|
self.calls.append(("handle_message", str(get_hermes_home())))
|
|
event._gateway_accepted = True
|
|
|
|
|
|
@pytest.fixture
|
|
def served(tmp_path, monkeypatch):
|
|
"""Default host + served profile ``alpha``; the runner is the default multiplexer."""
|
|
root = tmp_path / "hermes"
|
|
alpha = root / "profiles" / "alpha"
|
|
alpha.mkdir(parents=True)
|
|
(root / "config.yaml").write_text(
|
|
"gateway:\n multiplex_profiles: true\ndisplay:\n background_process_notifications: concise\n")
|
|
(alpha / "config.yaml").write_text("display:\n background_process_notifications: 'off'\n")
|
|
(root / ".env").write_text("")
|
|
(alpha / ".env").write_text("")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
|
|
set_multiplex_active(True)
|
|
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = SimpleNamespace(multiplex_profiles=True, profile_routes=[], get_notice_delivery=lambda p: "public")
|
|
runner._running = True
|
|
default_adapter, alpha_adapter = RecordingAdapter(), RecordingAdapter(notice_delivery="private")
|
|
runner.adapters = {Platform.TELEGRAM: default_adapter}
|
|
runner._profile_adapters = {"alpha": {Platform.TELEGRAM: alpha_adapter}}
|
|
runner._primary_profile_name = "default"
|
|
runner._session_source_cache = {}
|
|
runner.session_store = SimpleNamespace(_ensure_loaded=lambda: None, _entries={})
|
|
runner._completion_delivery_lock = threading.Lock()
|
|
runner._completion_deliveries_inflight = set()
|
|
runner._completion_deliveries_delivered = OrderedDict()
|
|
runner._completion_delivery_retention = 2048
|
|
runner._background_tasks = set()
|
|
runner._profile_failed_platforms = {}
|
|
try:
|
|
yield SimpleNamespace(root=root, alpha=alpha, runner=runner, alpha_adapter=alpha_adapter,
|
|
default_adapter=default_adapter)
|
|
finally:
|
|
set_multiplex_active(False)
|
|
|
|
|
|
def _alpha_source():
|
|
return SessionSource(platform=Platform.TELEGRAM, chat_id="1001", chat_type="dm", user_id="u1", profile="alpha")
|
|
|
|
|
|
def test_platform_notice_honours_the_served_profiles_notice_delivery(served):
|
|
"""alpha's ``platforms.telegram.notice_delivery: private`` (carried by ITS adapter) wins over the
|
|
launch profile's GatewayConfig — as a standalone alpha gateway would behave."""
|
|
runner = served.runner
|
|
runner._thread_metadata_for_source = lambda source: None
|
|
with _profile_runtime_scope(served.alpha):
|
|
asyncio.run(runner._deliver_platform_notice(_alpha_source(), "notice"))
|
|
assert [op for op, _ in served.alpha_adapter.calls] == ["send_private_notice"]
|
|
assert served.default_adapter.calls == []
|
|
|
|
|
|
def test_loop_completion_persists_into_the_served_profiles_store(served):
|
|
"""The post-turn /loop completion hop carries the profile contextvars: the completed tick lands
|
|
in alpha's state.db, not the default profile's."""
|
|
from hermes_cli.goals import _get_session_db
|
|
from hermes_cli.loops import LoopManager
|
|
|
|
runner = served.runner
|
|
entry = SimpleNamespace(session_id="sess-alpha-1", session_key="agent:alpha:telegram:dm:1001")
|
|
with _profile_runtime_scope(served.alpha):
|
|
_get_session_db()
|
|
mgr = LoopManager(session_id=entry.session_id)
|
|
mgr.set("check", interval_seconds=300, route={"platform": "telegram", "chat_id": "1001", "profile": "alpha"})
|
|
assert mgr.fire_tick()
|
|
asyncio.run(runner._post_turn_loop_completion(
|
|
session_entry=entry, source=_alpha_source(), final_response="done"))
|
|
|
|
def loop_row(home):
|
|
import sqlite3
|
|
db = home / "state.db"
|
|
if not db.exists():
|
|
return None
|
|
con = sqlite3.connect(db)
|
|
try:
|
|
row = con.execute("SELECT value FROM state_meta WHERE key=?", (f"loop:{entry.session_id}",)).fetchone()
|
|
finally:
|
|
con.close()
|
|
return json.loads(row[0]) if row else None
|
|
|
|
assert loop_row(served.alpha)["awaiting_response"] is False
|
|
assert loop_row(served.root) is None
|
|
|
|
|
|
def test_watch_event_gate_uses_the_owning_profiles_mode(served):
|
|
"""alpha has notifications ``off``; its watch event is drained silently even when the shared
|
|
queue is drained from the root context, exactly as a standalone alpha gateway would."""
|
|
from tools.process_registry import ProcessRegistry
|
|
|
|
runner = served.runner
|
|
registry = ProcessRegistry()
|
|
evt = {"type": "watch_match", "session_id": "p1", "session_key": "agent:alpha:telegram:dm:1001",
|
|
"platform": "telegram", "chat_type": "dm", "chat_id": "1001", "pattern": "DONE",
|
|
"output": "DONE", "command": "x"}
|
|
registry.completion_queue.put(evt)
|
|
asyncio.run(runner._drain_watch_notifications(registry.completion_queue))
|
|
assert registry.completion_queue.qsize() == 0
|
|
assert served.alpha_adapter.calls == []
|
|
assert served.default_adapter.calls == []
|
|
|
|
|
|
def test_served_profile_process_checkpoint_is_recovered_at_startup(served, monkeypatch):
|
|
"""A background process checkpointed during alpha's turn (alpha/processes.json) is re-adopted by
|
|
the multiplexer's startup recovery, once, with its watcher re-armed."""
|
|
from tools.process_registry import ProcessRegistry
|
|
|
|
runner = served.runner
|
|
import os
|
|
entry = {"session_id": "proc_alpha01", "pid": os.getpid(), "pid_scope": "host", "command": "sleep 1",
|
|
"started_at": 1.0, "watcher_interval": 5, "notify_on_complete": True,
|
|
"session_key": "agent:alpha:telegram:dm:1001"}
|
|
(served.alpha / "processes.json").write_text(json.dumps([entry]))
|
|
registry = ProcessRegistry()
|
|
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda pid, start: True)
|
|
|
|
recovered = registry.recover_from_checkpoint()
|
|
recovered += runner._recover_secondary_process_checkpoints(registry)
|
|
assert recovered == 1
|
|
assert [w["session_id"] for w in registry.pending_watchers] == ["proc_alpha01"]
|
|
# Idempotent across homes: the process-global registry already tracks it.
|
|
assert runner._recover_secondary_process_checkpoints(registry) == 0
|