mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
from hermes_cli import active_sessions
|
|
|
|
|
|
def test_resolve_max_concurrent_sessions_values(caplog):
|
|
assert active_sessions.resolve_max_concurrent_sessions({}) is None
|
|
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": None}) is None
|
|
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": 0}) is None
|
|
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": -1}) is None
|
|
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": "3"}) == 3
|
|
assert (
|
|
active_sessions.resolve_max_concurrent_sessions(
|
|
{"gateway": {"max_concurrent_sessions": 4}}
|
|
)
|
|
== 4
|
|
)
|
|
assert (
|
|
active_sessions.resolve_max_concurrent_sessions(
|
|
{"max_concurrent_sessions": 2, "gateway": {"max_concurrent_sessions": 4}}
|
|
)
|
|
== 2
|
|
)
|
|
|
|
caplog.set_level(logging.WARNING)
|
|
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": "many"}) is None
|
|
assert any(
|
|
"Ignoring invalid max_concurrent_sessions='many'" in record.message
|
|
for record in caplog.records
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cross_process_acquire_claims_only_one_last_slot(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
ready_dir = tmp_path / "ready"
|
|
ready_dir.mkdir()
|
|
results_dir = tmp_path / "results"
|
|
results_dir.mkdir()
|
|
go_file = tmp_path / "go"
|
|
env = os.environ.copy()
|
|
env["HERMES_HOME"] = str(home)
|
|
env["PYTHONPATH"] = str(repo_root)
|
|
script = (
|
|
"import os, time\n"
|
|
"from pathlib import Path\n"
|
|
"from hermes_cli.active_sessions import try_acquire_active_session\n"
|
|
"idx = os.environ['WORKER_INDEX']\n"
|
|
"worker_count = int(os.environ['WORKER_COUNT'])\n"
|
|
"delayed_worker = os.environ.get('DELAYED_WORKER_INDEX')\n"
|
|
"ready_dir = Path(os.environ['READY_DIR'])\n"
|
|
"results_dir = Path(os.environ['RESULTS_DIR'])\n"
|
|
"go_file = Path(os.environ['GO_FILE'])\n"
|
|
"(ready_dir / idx).write_text('ready', encoding='utf-8')\n"
|
|
"deadline = time.time() + 10\n"
|
|
"while not go_file.exists():\n"
|
|
" if time.time() > deadline:\n"
|
|
" raise RuntimeError('timed out waiting for go file')\n"
|
|
" time.sleep(0.01)\n"
|
|
"if idx == delayed_worker:\n"
|
|
" time.sleep(2.5)\n"
|
|
"lease, message = try_acquire_active_session(\n"
|
|
" session_id=f'process-{idx}',\n"
|
|
" surface='cli',\n"
|
|
" config={'max_concurrent_sessions': 1},\n"
|
|
")\n"
|
|
"if lease is None:\n"
|
|
" (results_dir / idx).write_text('BLOCK', encoding='utf-8')\n"
|
|
" print('BLOCK', flush=True)\n"
|
|
"else:\n"
|
|
" (results_dir / idx).write_text('OK', encoding='utf-8')\n"
|
|
" print('OK', flush=True)\n"
|
|
" deadline = time.time() + 10\n"
|
|
" while len(list(results_dir.iterdir())) < worker_count:\n"
|
|
" if time.time() > deadline:\n"
|
|
" raise RuntimeError('timed out waiting for all workers to attempt acquire')\n"
|
|
" time.sleep(0.01)\n"
|
|
" lease.release()\n"
|
|
)
|
|
workers: list[subprocess.Popen[str]] = []
|
|
try:
|
|
for index in range(6):
|
|
worker_env = env.copy()
|
|
worker_env["WORKER_INDEX"] = str(index)
|
|
worker_env["WORKER_COUNT"] = "6"
|
|
worker_env["DELAYED_WORKER_INDEX"] = "5"
|
|
worker_env["READY_DIR"] = str(ready_dir)
|
|
worker_env["RESULTS_DIR"] = str(results_dir)
|
|
worker_env["GO_FILE"] = str(go_file)
|
|
workers.append(
|
|
subprocess.Popen(
|
|
[sys.executable, "-c", script],
|
|
env=worker_env,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
)
|
|
|
|
deadline = time.time() + 10
|
|
while len(list(ready_dir.iterdir())) < len(workers):
|
|
if time.time() > deadline:
|
|
raise AssertionError("workers did not become ready")
|
|
time.sleep(0.01)
|
|
go_file.write_text("go", encoding="utf-8")
|
|
|
|
outputs = []
|
|
for worker in workers:
|
|
stdout, stderr = worker.communicate(timeout=10)
|
|
assert worker.returncode == 0, stderr
|
|
outputs.append(stdout.strip())
|
|
finally:
|
|
for worker in workers:
|
|
if worker.poll() is None:
|
|
worker.kill()
|
|
worker.communicate()
|
|
|
|
assert outputs.count("OK") == 1
|
|
assert outputs.count("BLOCK") == len(workers) - 1
|
|
assert active_sessions.active_session_registry_snapshot() == []
|
|
|
|
|
|
|
|
|
|
def test_release_orphaned_leases_reclaims_only_unowned_own_pid_entries(tmp_path, monkeypatch):
|
|
"""A long-lived server must reclaim leases whose session skipped teardown.
|
|
|
|
``_prune_dead`` only fires when the owning pid dies, so a ``hermes
|
|
dashboard`` running for days holds a leaked lease until restart. The
|
|
process reconciles against the leases it still owns instead.
|
|
"""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
cfg = {"max_concurrent_sessions": 5}
|
|
kept, orphan = (
|
|
active_sessions.try_acquire_active_session(
|
|
session_id=sid, surface="desktop", config=cfg
|
|
)[0]
|
|
for sid in ("kept", "orphaned")
|
|
)
|
|
# Another live process's lease is not ours to reclaim.
|
|
active_sessions._write_entries(
|
|
active_sessions._state_path(),
|
|
active_sessions._read_entries(active_sessions._state_path())
|
|
+ [{"lease_id": "elsewhere", "session_id": "other", "surface": "cli", "pid": os.getpid() }],
|
|
)
|
|
|
|
assert active_sessions.release_orphaned_leases({kept.lease_id, "elsewhere"}) == 1
|
|
assert sorted(
|
|
entry["session_id"]
|
|
for entry in active_sessions.active_session_registry_snapshot()
|
|
) == ["kept", "other"]
|
|
assert orphan is not None
|