Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"""Handoff watcher must poll EVERY served profile's store, not just the root.
|
||||
|
||||
Regression guard for the multi-profile ``/handoff`` bug: ``/handoff`` writes
|
||||
``handoff_state='pending'`` into the store of the profile the CLI ran under
|
||||
(``hermes -p medicina``), while the gateway's watcher resolves ``_session_db``
|
||||
from whatever HERMES_HOME is active on its task. Unscoped that is always the
|
||||
ROOT store, so the pending row was never seen and the CLI timed out with the
|
||||
gateway plainly alive and connected.
|
||||
|
||||
These tests pin the two halves of the fix:
|
||||
1. the scope list includes the root (``None``) plus every multiplexed home,
|
||||
and degrades to ``[None]`` for a single-profile gateway;
|
||||
2. the watcher actually enters ``_profile_runtime_scope`` for each non-root
|
||||
home, which is what re-points ``_session_db`` at that profile's store.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import run
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
def __init__(self, multiplex, allowlist=None):
|
||||
self.multiplex_profiles = multiplex
|
||||
self.multiplex_profile_allowlist = allowlist
|
||||
|
||||
|
||||
def test_scopes_single_profile_gateway_is_root_only():
|
||||
"""No multiplexing → exactly the legacy unscoped poll."""
|
||||
runner = types.SimpleNamespace(config=_FakeConfig(multiplex=False))
|
||||
assert run._handoff_watch_scopes(runner) == [(None, None)]
|
||||
|
||||
|
||||
def test_scopes_include_root_first_then_every_secondary_home(monkeypatch):
|
||||
"""Multiplexed → root first, then each SECONDARY profile as (name, home).
|
||||
|
||||
The default profile must NOT be yielded again: its home resolves to the
|
||||
same ``state.db`` as the unscoped root poll, so repeating it would double
|
||||
every tick's query count for zero benefit.
|
||||
"""
|
||||
homes = [
|
||||
("default", Path("/h")),
|
||||
("bala", Path("/h/profiles/bala")),
|
||||
("medicina", Path("/h/profiles/medicina")),
|
||||
]
|
||||
monkeypatch.setattr(run, "_multiplex_profile_homes", lambda _cfg: homes)
|
||||
|
||||
runner = types.SimpleNamespace(config=_FakeConfig(multiplex=True))
|
||||
scopes = run._handoff_watch_scopes(runner)
|
||||
|
||||
assert scopes[0] == (None, None), "root store must still be polled first"
|
||||
assert scopes[1:] == [
|
||||
("bala", Path("/h/profiles/bala")),
|
||||
("medicina", Path("/h/profiles/medicina")),
|
||||
]
|
||||
assert not any(name == "default" for name, _h in scopes[1:]), (
|
||||
"default profile must not be polled twice per tick"
|
||||
)
|
||||
|
||||
|
||||
def test_scopes_degrade_to_root_when_resolution_raises(monkeypatch):
|
||||
"""A broken profile resolver must not disable the watcher entirely."""
|
||||
def _boom(_cfg):
|
||||
raise RuntimeError("profiles dir unreadable")
|
||||
|
||||
monkeypatch.setattr(run, "_multiplex_profile_homes", _boom)
|
||||
runner = types.SimpleNamespace(config=_FakeConfig(multiplex=True))
|
||||
assert run._handoff_watch_scopes(runner) == [(None, None)]
|
||||
|
||||
|
||||
def test_scopes_tolerate_runner_without_config():
|
||||
"""The watcher's own unit tests bind onto a config-less stand-in."""
|
||||
assert run._handoff_watch_scopes(types.SimpleNamespace()) == [(None, None)]
|
||||
|
||||
|
||||
class _RecordingDB:
|
||||
"""Minimal AsyncSessionDB-shaped stub; records nothing pending."""
|
||||
|
||||
def __init__(self, tag=None):
|
||||
self.polls = 0
|
||||
self.tag = tag
|
||||
|
||||
async def list_pending_handoffs(self):
|
||||
self.polls += 1
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watcher_enters_profile_scope_for_each_home(monkeypatch):
|
||||
"""Each non-root home is polled INSIDE ``_profile_runtime_scope``.
|
||||
|
||||
Entering that scope is the whole point of the fix — it is what redirects
|
||||
``_session_db`` to the profile's own ``state.db``. Asserting on the scope
|
||||
entries (not just the poll count) keeps the test mutation-survivable:
|
||||
dropping the ``with`` still polls N times but records no scopes.
|
||||
"""
|
||||
scopes = [
|
||||
(None, None),
|
||||
("bala", Path("/h/profiles/bala")),
|
||||
("medicina", Path("/h/profiles/medicina")),
|
||||
]
|
||||
monkeypatch.setattr(run, "_handoff_watch_scopes", lambda _r: scopes)
|
||||
|
||||
entered = []
|
||||
|
||||
class _SpyScope:
|
||||
def __init__(self, home):
|
||||
self.home = home
|
||||
|
||||
async def __aenter__(self):
|
||||
entered.append(self.home)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(run, "_async_profile_runtime_scope", _SpyScope)
|
||||
|
||||
async def _no_sleep(_seconds):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(run.asyncio, "sleep", _no_sleep)
|
||||
|
||||
db = _RecordingDB()
|
||||
states = iter([True, False])
|
||||
|
||||
class _Running:
|
||||
def __bool__(_self):
|
||||
try:
|
||||
return next(states)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
fake = types.SimpleNamespace()
|
||||
fake._session_db = db
|
||||
fake._running = _Running()
|
||||
|
||||
async def _process_handoff(row, profile_name=None):
|
||||
return None
|
||||
|
||||
fake._process_handoff = _process_handoff
|
||||
|
||||
coro = run.GatewayRunner._handoff_watcher(fake, interval=0.0)
|
||||
await asyncio.wait_for(coro, timeout=5)
|
||||
|
||||
secondary_homes = [h for _n, h in scopes[1:]]
|
||||
# Each secondary home is entered TWICE per watcher run: once by the
|
||||
# startup stale-handoff reclaim, once by the poll tick. Both must be
|
||||
# scoped — a reclaim outside the scope would clear the ROOT store's rows
|
||||
# while reporting the profile's.
|
||||
assert entered == secondary_homes * 2, (
|
||||
"each secondary home must be scoped for BOTH the startup reclaim "
|
||||
f"and the poll tick; got {entered}"
|
||||
)
|
||||
assert db.polls == 3, "root + both profiles polled once each per tick"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_profile_secret_load_does_not_block_event_loop(monkeypatch, tmp_path):
|
||||
"""A slow profile ``.env`` read must not stall unrelated loop work."""
|
||||
profile_home = tmp_path / "profiles" / "slow"
|
||||
profile_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(
|
||||
run,
|
||||
"_handoff_watch_scopes",
|
||||
lambda _runner: [(None, None), ("slow", profile_home)],
|
||||
)
|
||||
|
||||
from agent import secret_scope
|
||||
|
||||
load_started = threading.Event()
|
||||
ticker_progressed = threading.Event()
|
||||
ticker_progressed_while_loading = []
|
||||
|
||||
def _slow_build(_home):
|
||||
load_started.set()
|
||||
ticker_progressed_while_loading.append(
|
||||
ticker_progressed.wait(timeout=2)
|
||||
)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(secret_scope, "build_profile_secret_scope", _slow_build)
|
||||
|
||||
class _DB:
|
||||
async def list_pending_handoffs(self):
|
||||
return []
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
_session_db=_DB(),
|
||||
_running=False,
|
||||
)
|
||||
|
||||
async def _process_handoff(_row, _profile_name=None):
|
||||
return None
|
||||
|
||||
fake._process_handoff = _process_handoff
|
||||
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def _skip_initial_delay(seconds):
|
||||
await real_sleep(0 if seconds == 5 else seconds)
|
||||
|
||||
monkeypatch.setattr(run.asyncio, "sleep", _skip_initial_delay)
|
||||
async def _ticker():
|
||||
assert await asyncio.to_thread(load_started.wait, 5)
|
||||
ticker_progressed.set()
|
||||
|
||||
watcher = asyncio.create_task(
|
||||
run.GatewayRunner._handoff_watcher(fake, interval=0.0)
|
||||
)
|
||||
ticker = asyncio.create_task(_ticker())
|
||||
await asyncio.wait_for(asyncio.gather(watcher, ticker), timeout=5)
|
||||
|
||||
assert ticker_progressed_while_loading == [True], (
|
||||
"profile secret loading blocked the asyncio event loop until the "
|
||||
"filesystem operation completed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_scope_resolves_its_own_store_and_profile(monkeypatch):
|
||||
"""The whole point: a DIFFERENT ``state.db`` per scope, and the profile
|
||||
name reaches ``_process_handoff`` so delivery uses that profile's adapter.
|
||||
|
||||
The earlier test proves the ``with`` runs; it cannot prove the store was
|
||||
re-resolved, because it pins one fake db for every scope. Here
|
||||
``_session_db`` is a property whose value depends on the active scope, and
|
||||
each store yields a pending row tagged with its profile — so a regression
|
||||
that polls the root three times, or that drops ``profile_name``, fails.
|
||||
"""
|
||||
scopes = [
|
||||
(None, None),
|
||||
("bala", Path("/h/profiles/bala")),
|
||||
("medicina", Path("/h/profiles/medicina")),
|
||||
]
|
||||
monkeypatch.setattr(run, "_handoff_watch_scopes", lambda _r: scopes)
|
||||
|
||||
active = {"home": None}
|
||||
|
||||
class _SpyScope:
|
||||
def __init__(self, home):
|
||||
self.home = home
|
||||
|
||||
async def __aenter__(self):
|
||||
active["home"] = self.home
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
active["home"] = None
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(run, "_async_profile_runtime_scope", _SpyScope)
|
||||
|
||||
async def _no_sleep(_seconds):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(run.asyncio, "sleep", _no_sleep)
|
||||
|
||||
class _ScopedDB:
|
||||
"""Yields a row whose id identifies the store it came from."""
|
||||
|
||||
def __init__(self, tag):
|
||||
self.tag = tag
|
||||
|
||||
async def list_pending_handoffs(self):
|
||||
return [{"id": f"row-from-{self.tag}"}]
|
||||
|
||||
async def claim_handoff(self, _sid):
|
||||
return True
|
||||
|
||||
async def complete_handoff(self, _sid):
|
||||
return None
|
||||
|
||||
async def fail_handoff(self, _sid, _err):
|
||||
return None
|
||||
|
||||
stores = {
|
||||
None: _ScopedDB("root"),
|
||||
Path("/h/profiles/bala"): _ScopedDB("bala"),
|
||||
Path("/h/profiles/medicina"): _ScopedDB("medicina"),
|
||||
}
|
||||
|
||||
processed = []
|
||||
states = iter([True, False])
|
||||
|
||||
class _Fake:
|
||||
@property
|
||||
def _running(self):
|
||||
try:
|
||||
return next(states)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
@property
|
||||
def _session_db(self):
|
||||
# Mirrors the real property: resolves from the ACTIVE scope.
|
||||
return stores[active["home"]]
|
||||
|
||||
async def _process_handoff(self, row, profile_name=None):
|
||||
processed.append((row["id"], profile_name))
|
||||
|
||||
coro = run.GatewayRunner._handoff_watcher(_Fake(), interval=0.0)
|
||||
await asyncio.wait_for(coro, timeout=5)
|
||||
|
||||
assert processed == [
|
||||
("row-from-root", None),
|
||||
("row-from-bala", "bala"),
|
||||
("row-from-medicina", "medicina"),
|
||||
], "each scope must resolve its own store AND pass its profile name through"
|
||||
Reference in New Issue
Block a user