{"repo":"NousResearch/hermes-agent","public_id":"54523","display_label":"Offload blocking profiles endpoints from asyncio loop","summary":"A 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.","subsystem":"dashboard","counts":{"members":5,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":0,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54770,"merged_at":"2026-06-29T09:35:57+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-29T09:35:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":54523,"pr_number":54770,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-29T09:35:57+00:00"}],"members":[{"kind":"issue","number":54523,"title":"[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","url":null,"state":"open"},{"kind":"pr","number":54560,"title":"fix: offload blocking profiles endpoints from asyncio event loop (#54523)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1bb7b59c5de4eee89a9f47f93a48471ba4fd6605","number":0},{"kind":"commit","value":"09f10e2b77e082667911e7c6a42d607085271ebb","number":0},{"kind":"commit","value":"f64e4f4f5","number":0},{"kind":"pr","value":"#54523","number":54523}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":54751,"title":"perf(profiles): speed up list_profiles (single-pass alias map + skill-count cache + event-loop offload)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54770","number":54770}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54770,"title":"perf(profiles): single-pass list_profiles alias map + skill-count cache + event-loop offload","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/profiles.py","hermes_cli/web_server.py","infographic/list-profiles-perf-54751/infographic.png","scripts/release.py"],"fix_hunks":[{"file":"hermes_cli/profiles.py","hunks":["@@ -27,6 +27,7 @@\n import stat\n import subprocess\n import sys\n+import time\n from dataclasses import dataclass\n from pathlib import Path, PurePosixPath, PureWindowsPath\n from typing import List, Optional, Tuple","@@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:\n     A custom alias (name != profile) is preferred over the profile-named wrapper\n     so ``profile list``/``show`` surface the command the user actually typed.\n     Results are sorted for deterministic output when several aliases match.\n+\n+    For listing ALL profiles at once, prefer :func:`build_alias_map` — calling\n+    this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper\n+    dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg\n+    etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts.\n+    \"\"\"\n+    return build_alias_map().get(normalize_profile_name(profile_name))\n+\n+\n+# Cap how much of a wrapper file we read when reverse-looking-up its profile.\n+# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``)\n+# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds\n+# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was\n+# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and\n+# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass.\n+_WRAPPER_READ_LIMIT = 8192\n+\n+\n+def build_alias_map() -> dict[str, str]:\n+    \"\"\"Single-pass reverse map ``{canonical_profile -> alias_name}``.\n+\n+    Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile)\n+    and reads only a small head slice of each candidate wrapper, skipping\n+    binaries. A custom alias (file name != profile) wins over the profile-named\n+    wrapper, matching ``find_alias_for_profile``'s preference; deterministic via\n+    sorted iteration.\n     \"\"\"\n     wrapper_dir = _get_wrapper_dir()\n+    result: dict[str, str] = {}\n     if not wrapper_dir.is_dir():\n-        return None\n-    canon = normalize_profile_name(profile_name)\n+        return result\n     is_windows = sys.platform == \"win32\"\n-    needle = f\"hermes -p {canon}\"\n+    prefix = \"hermes -p \"\n \n-    custom: Optional[str] = None\n-    profile_named: Optional[str] = None\n     for entry in sorted(wrapper_dir.iterdir()):\n         if not entry.is_file():\n             continue","@@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:\n         if not is_windows and entry.suffix:\n             continue\n         try:\n-            content = entry.read_text()\n+            with open(entry, \"r\", encoding=\"utf-8\", errors=\"strict\") as f:\n+                content = f.read(_WRAPPER_READ_LIMIT)\n         except (OSError, UnicodeDecodeError):\n+            # UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.\n+            continue\n+        idx = content.find(prefix)\n+        if idx == -1:\n             continue\n-        if needle not in content:\n+        rest = content[idx + len(prefix):]\n+        # Profile id is the first whitespace-delimited token after the flag.\n+        canon = rest.split(None, 1)[0].strip() if rest.strip() else \"\"\n+        if not canon:\n             continue\n+        canon = normalize_profile_name(canon)\n         alias = entry.stem if is_windows else entry.name\n+        # Custom alias (name != profile) preferred; otherwise keep the\n+        # profile-named wrapper. Don't overwrite a custom alias already found.\n         if alias == canon:\n-            profile_named = alias\n-        elif custom is None:\n-            custom = alias\n-    return custom if custom is not None else profile_named\n+            result.setdefault(canon, alias)\n+        else:\n+            result[canon] = alias\n+    return result\n \n \n # ---------------------------------------------------------------------------","@@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:\n         return False\n \n \n+# In-process cache for skill counts. Walking ``skills_dir.rglob(\"SKILL.md\")``\n+# recurses the entire skill tree (each skill carries references/scripts/assets\n+# sub-trees); the default profile alone has ~270 skills, and ``list_profiles``\n+# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long\n+# enough that the desktop's per-request backend calls time out and the sidebar\n+# renders \"全部智能体 0\". We cache the count keyed by the skills dir, invalidated\n+# when the dir tree's signature (skills_dir + immediate category dirs mtimes)\n+# changes (catches skill add/remove) or after a short TTL (catches deep edits).\n+_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {}\n+_SKILL_COUNT_TTL_SECONDS = 30.0\n+\n+\n+def _skills_dir_signature(skills_dir: Path) -> float:\n+    \"\"\"Cheap change-signature for a skills tree.\n+\n+    Max mtime of ``skills_dir`` and its immediate children (category dirs).\n+    Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a\n+    skill inside a category bumps that category dir's mtime. One ``scandir``\n+    (not a recursive walk) keeps this O(#categories), not O(#files).\n+    \"\"\"\n+    try:\n+        sig = skills_dir.stat().st_mtime\n+    except OSError:\n+        return 0.0\n+    try:\n+        with os.scandir(skills_dir) as it:\n+            for entry in it:\n+                try:\n+                    if entry.is_dir(follow_symlinks=False):\n+                        m = entry.stat(follow_symlinks=False).st_mtime\n+                        if m > sig:\n+                            sig = m\n+                except OSError:\n+                    continue\n+    except OSError:\n+        pass\n+    return sig\n+\n+\n def _count_skills(profile_dir: Path) -> int:\n-    \"\"\"Count installed skills in a profile.\"\"\"\n+    \"\"\"Count installed skills in a profile (cached by skills-dir signature).\"\"\"\n     skills_dir = profile_dir / \"skills\"\n     if not skills_dir.is_dir():\n         return 0\n+\n+    key = str(skills_dir)\n+    signature = _skills_dir_signature(skills_dir)\n+    now = time.time()\n+    cached = _SKILL_COUNT_CACHE.get(key)\n+    if (\n+        cached is not None\n+        and cached[0] == signature\n+        and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS\n+    ):\n+        return cached[2]\n+\n     count = 0\n     for md in skills_dir.rglob(\"SKILL.md\"):\n         if is_excluded_skill_path(md):\n             continue\n         count += 1\n+    _SKILL_COUNT_CACHE[key] = (signature, now, count)\n     return count\n \n ","@@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]:\n     # Named profiles\n     profiles_root = _get_profiles_root()\n     if profiles_root.is_dir():\n+        # Build the {profile -> alias} map ONCE here instead of calling\n+        # find_alias_for_profile() per profile (which re-scanned the whole\n+        # wrapper dir each time — O(N*M), the dominant cost in this function).\n+        alias_map = build_alias_map()\n         for entry in sorted(profiles_root.iterdir()):\n             if not entry.is_dir():\n                 continue","@@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]:\n             if not _PROFILE_ID_RE.match(name):\n                 continue\n             model, provider = _read_config_model(entry)\n-            alias_name = find_alias_for_profile(name)\n+            alias_name = alias_map.get(normalize_profile_name(name))\n             if alias_name:\n                 is_windows = sys.platform == \"win32\"\n                 alias_path = wrapper_dir / (f\"{alias_name}.bat\" if is_windows else alias_name)"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -3430,7 +3430,7 @@ async def get_sessions(\n \n \n @app.get(\"/api/profiles/sessions\")\n-async def get_profiles_sessions(\n+def get_profiles_sessions(\n     limit: int = 20,\n     offset: int = 0,\n     min_messages: int = 0,","@@ -10719,7 +10719,9 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:\n async def list_profiles_endpoint():\n     from hermes_cli import profiles as profiles_mod\n     try:\n-        return {\"profiles\": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]}\n+        loop = asyncio.get_running_loop()\n+        profiles = await loop.run_in_executor(None, profiles_mod.list_profiles)\n+        return {\"profiles\": [_profile_to_dict(p) for p in profiles]}\n     except Exception:\n         _log.exception(\"GET /api/profiles failed; falling back to profile directory scan\")\n         return {\"profiles\": _fallback_profile_dicts(profiles_mod)}"]},{"file":"scripts/release.py","hunks":["@@ -1710,6 +1710,7 @@\n     \"[redacted:email]\": \"infinitycrew39\",  # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)\n     \"[redacted:email]\": \"huangxun375-stack\",  # PR #37251 / #48894 structured OpenViking sync\n     \"218421507+Sahil-SS9@users.noreply.github.com\": \"Sahil-SS9\",  # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill)\n+    \"[redacted:email]\": \"max-chen\",  # PR #51194 salvage (single-pass list_profiles alias map + skill-count cache; #54751)\n     # v0.17.0 additions\n     \"[redacted:email]\": \"pengyuyanITYU\",  # PR #43618 (harden local file tree paths)\n     \"[redacted:email]\": \"AIalliAI\",  # PR #44159 (desktop hover-reveal inset)\n"]}]},{"kind":"pr","number":140923,"title":"fix(dashboard): verify session tokens off the serve event loop","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}