Offload blocking profiles endpoints from asyncio loop
Cluster 54523 · dashboard. Data as of
2 open items. Fix merged in #54770 on 29 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 0 issues already closed.
- Fix event
- A fix was merged to main in PR #54770. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 1 issue and 1 PR.
Next step: read #54770, then check whether your report is the one still open.
Read #54770Pick up a Lab taskA bug was identified where profiles-related endpoints (list_profiles, /api/profiles/sessions) block the asyncio event loop for 10–25 seconds, starving WebSocket connections. These members address that problem through event-loop offloading, a single-pass alias map, and a skill-count cache.
Open issues1
- Issue#54523[Bug]: Remote desktop over Tailscale: async routes block the asyncio loop 10-25s (list_profiles, /api/profiles/sessions) starving the WS; plus Chromium LNA + Electron net stalls
Open PRs1
- PR#140923fix(dashboard): verify session tokens off the serve event loop
Fixes on record
-
#54770 perf(profiles): single-pass list_profiles alias map + skill-count cache + event-loop offload
files:
hermes_cli/profiles.py,hermes_cli/web_server.py,infographic/list-profiles-perf-54751/infographic.png,scripts/release.pyShow the diff
-
hermes_cli/profiles.py@@ -27,6 +27,7 @@ import stat import subprocess import sys +import time from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath from typing import List, Optional, Tuple @@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]: A custom alias (name != profile) is preferred over the profile-named wrapper so ``profile list``/``show`` surface the command the user actually typed. Results are sorted for deterministic output when several aliases match. + + For listing ALL profiles at once, prefer :func:`build_alias_map` — calling + this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper + dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg + etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts. + """ + return build_alias_map().get(normalize_profile_name(profile_name)) + + +# Cap how much of a wrapper file we read when reverse-looking-up its profile. +# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``) +# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds +# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was +# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and +# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass. +_WRAPPER_READ_LIMIT = 8192 + + +def build_alias_map() -> dict[str, str]: + """Single-pass reverse map ``{canonical_profile -> alias_name}``. + + Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) + and reads only a small head slice of each candidate wrapper, skipping + binaries. A custom alias (file name != profile) wins over the profile-named + wrapper, matching ``find_alias_for_profile``'s preference; deterministic via + sorted iteration. """ wrapper_dir = _get_wrapper_dir() + result: dict[str, str] = {} if not wrapper_dir.is_dir(): - return None - canon = normalize_profile_name(profile_name) + return result is_windows = sys.platform == "win32" - needle = f"hermes -p {canon}" + prefix = "hermes -p " - custom: Optional[str] = None - profile_named: Optional[str] = None for entry in sorted(wrapper_dir.iterdir()): if not entry.is_file(): continue @@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]: if not is_windows and entry.suffix: continue try: - content = entry.read_text() + with open(entry, "r", encoding="utf-8", errors="strict") as f: + content = f.read(_WRAPPER_READ_LIMIT) except (OSError, UnicodeDecodeError): + # UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper. + continue + idx = content.find(prefix) + if idx == -1: continue - if needle not in content: + rest = content[idx + len(prefix):] + # Profile id is the first whitespace-delimited token after the flag. + canon = rest.split(None, 1)[0].strip() if rest.strip() else "" + if not canon: continue + canon = normalize_profile_name(canon) alias = entry.stem if is_windows else entry.name + # Custom alias (name != profile) preferred; otherwise keep the + # profile-named wrapper. Don't overwrite a custom alias already found. if alias == canon: - profile_named = alias - elif custom is None: - custom = alias - return custom if custom is not None else profile_named + result.setdefault(canon, alias) + else: + result[canon] = alias + return result # --------------------------------------------------------------------------- @@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool: return False +# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")`` +# recurses the entire skill tree (each skill carries references/scripts/assets +# sub-trees); the default profile alone has ~270 skills, and ``list_profiles`` +# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long +# enough that the desktop's per-request backend calls time out and the sidebar +# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated +# when the dir tree's signature (skills_dir + immediate category dirs mtimes) +# changes (catches skill add/remove) or after a short TTL (catches deep edits). +_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {} +_SKILL_COUNT_TTL_SECONDS = 30.0 + + +def _skills_dir_signature(skills_dir: Path) -> float: + """Cheap change-signature for a skills tree. + + Max mtime of ``skills_dir`` and its immediate children (category dirs). + Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a + skill inside a category bumps that category dir's mtime. One ``scandir`` + (not a recursive walk) keeps this O(#categories), not O(#files). + """ + try: + sig = skills_dir.stat().st_mtime + except OSError: + return 0.0 + try: + with os.scandir(skills_dir) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + m = entry.stat(follow_symlinks=False).st_mtime + if m > sig: + sig = m + except OSError: + continue + except OSError: + pass + return sig + + def _count_skills(profile_dir: Path) -> int: - """Count installed skills in a profile.""" + """Count installed skills in a profile (cached by skills-dir signature).""" skills_dir = profile_dir / "skills" if not skills_dir.is_dir(): return 0 + + key = str(skills_dir) + signature = _skills_dir_signature(skills_dir) + now = time.time() + cached = _SKILL_COUNT_CACHE.get(key) + if ( + cached is not None + and cached[0] == signature + and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS + ): + return cached[2] + count = 0 for md in skills_dir.rglob("SKILL.md"): if is_excluded_skill_path(md): continue count += 1 + _SKILL_COUNT_CACHE[key] = (signature, now, count) return count @@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]: # Named profiles profiles_root = _get_profiles_root() if profiles_root.is_dir(): + # Build the {profile -> alias} map ONCE here instead of calling + # find_alias_for_profile() per profile (which re-scanned the whole + # wrapper dir each time — O(N*M), the dominant cost in this function). + alias_map = build_alias_map() for entry in sorted(profiles_root.iterdir()): if not entry.is_dir(): continue @@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]: if not _PROFILE_ID_RE.match(name): continue model, provider = _read_config_model(entry) - alias_name = find_alias_for_profile(name) + alias_name = alias_map.get(normalize_profile_name(name)) if alias_name: is_windows = sys.platform == "win32" alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name) -
hermes_cli/web_server.py@@ -3430,7 +3430,7 @@ async def get_sessions( @app.get("/api/profiles/sessions") -async def get_profiles_sessions( +def get_profiles_sessions( limit: int = 20, offset: int = 0, min_messages: int = 0, @@ -10719,7 +10719,9 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod try: - return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} + loop = asyncio.get_running_loop() + profiles = await loop.run_in_executor(None, profiles_mod.list_profiles) + return {"profiles": [_profile_to_dict(p) for p in profiles]} except Exception: _log.exception("GET /api/profiles failed; falling back to profile directory scan") return {"profiles": _fallback_profile_dicts(profiles_mod)} -
scripts/release.py@@ -1710,6 +1710,7 @@ "[redacted:email]": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292) "[redacted:email]": "huangxun375-stack", # PR #37251 / #48894 structured OpenViking sync "[email protected]": "Sahil-SS9", # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill) + "[redacted:email]": "max-chen", # PR #51194 salvage (single-pass list_profiles alias map + skill-count cache; #54751) # v0.17.0 additions "[redacted:email]": "pengyuyanITYU", # PR #43618 (harden local file tree paths) "[redacted:email]": "AIalliAI", # PR #44159 (desktop hover-reveal inset)
-
2 PRs closed without merging
From the close comments: merged via another change 2
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- dashboard
- Merged PRs
- #54770
- Closed issues
- 0
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 29 Jun 2026