Files
hermes-agent/tests/hermes_cli/test_web_read_coalescing.py
teknium1 4590ef8b58 fix(profiles): polled profile lists never walk skill trees; vanished skill dirs no longer abort enumeration
GET /api/profiles, the profiles.list RPC and the /api/profiles/projects/tree
fan-out are polled by the Desktop every few seconds (roster tick, focus,
gateway-open). Each call ran list_profiles() -> _count_skills() ->
Path.rglob("SKILL.md") over EVERY profile once the 30 s TTL expired: ~4 fs
calls per skill, 2.4-4.7 s per walk on 7-84 profile installs, ~half a core
at idle, and on Linux enough to starve the renderer's 60 s API timeout. A
skill dir removed mid-walk raised FileNotFoundError out of rglob and aborted
the whole profile list.

- list_profiles(lazy_skill_count=True): skill_count is the last known value;
  a missing/aged entry schedules ONE background _count_skills per profile per
  60 s recheck window, so the request thread does zero skill-tree I/O and the
  refresh cadence is decoupled from the poll rate. The two polled callers and
  the REST fallback entry use it; the sync default (CLI, detail views) is
  unchanged.
- _walk_skill_count: the repo walker (agent.skill_utils.iter_skill_index_files,
  os.walk with excluded/support dirs pruned) instead of rglob — half the
  fs calls and best-effort on subtrees that vanish mid-walk. profiles.describe
  uses the same walker.
- _profile_targets always uses profiles_to_serve (pure directory read):
  projects/tree and sessions/pull-requests only ever consumed name/path.
- Skill-count TTL 30 s -> 600 s (signature invalidation still catches
  skill add/remove immediately on the next refresh).

Live repro (5 profiles x 200 SKILL.md, temp HERMES_HOME, py3.11):
  before: GET /api/profiles 2175 stat + 2015 scandir per cold call,
          profiles.list 2189 + 2015, projects/tree 2177 + 2015;
          a skill dir removed mid-walk -> FileNotFoundError from list_profiles()
  after:  GET /api/profiles 0 skill-tree stats/scandir on the request thread
          (counts land from the background refresh by the next poll),
          profiles.list 0, projects/tree 0; _count_skills (detail/control)
          still reports 200 with 203 scandir; the vanished-dir case returns
          199 and list_profiles() enumerates all 5 profiles.

Fixes #114041
Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-18 10:18:40 -07:00

69 lines
2.3 KiB
Python

"""GET /api/profiles coalesces a poll burst before AnyIO threadpool admission (#105179)."""
import asyncio
import threading
from functools import wraps
import pytest
async def wait_until(predicate):
async with asyncio.timeout(3):
while not predicate():
await asyncio.sleep(0.005)
def async_test(fn):
@wraps(fn)
def run(*args, **kwargs):
return asyncio.run(fn(*args, **kwargs))
return run
@async_test
async def test_profiles_burst_leaves_threadpool_status_responsive(monkeypatch):
import anyio.to_thread
import httpx
from fastapi import FastAPI
from hermes_cli import profiles as profiles_mod
from hermes_cli.web_routers import profiles
from starlette.concurrency import run_in_threadpool
release = threading.Event()
calls = []
def slow_profiles(**_kwargs): # the router passes ``lazy_skill_count=True``
calls.append(1)
assert release.wait(3)
return ["example"]
monkeypatch.setattr(profiles_mod, "list_profiles", slow_profiles)
monkeypatch.setattr(profiles, "_profile_to_dict", lambda p: {"name": p})
monkeypatch.setattr(profiles, "run_in_threadpool", run_in_threadpool)
app = FastAPI()
app.include_router(profiles.router)
@app.get("/api/status")
def status():
return {"ok": True}
limiter = anyio.to_thread.current_default_thread_limiter()
previous = limiter.total_tokens
limiter.total_tokens = 2
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
tasks = [asyncio.create_task(client.get("/api/profiles")) for _ in range(12)]
try:
await wait_until(lambda: calls)
await asyncio.sleep(0.05)
response = await asyncio.wait_for(client.get("/api/status"), 0.5)
assert response.status_code == 200
assert response.json() == {"ok": True}
assert calls == [1]
assert limiter.borrowed_tokens == 1
finally:
release.set()
responses = await asyncio.gather(*tasks)
assert all(r.status_code == 200 for r in responses)
assert all(r.json() == {"profiles": [{"name": "example"}]} for r in responses)
finally:
limiter.total_tokens = previous