perf: fast model picker + dialogs — config-load hot path, model.options off the reader thread, off-screen turns skip rendering

Third profiling round (after #66033 / #66347), targeting the composer
model picker and dialog opens (worktree dialog etc.), measured over CDP
on real 1000+-message sessions.

Backend — model.options took 4.8s cold / 1.8s warm per call, and the
desktop model pill/picker blocks on it every open:

- agent/credential_pool: _load_config_safe uses load_config_readonly().
  Every consumer only reads, and the per-call deepcopy was the dominant
  cost — list_authenticated_providers calls load_pool() per provider
  row, and each load_pool loaded (and deep-copied) the full config
  again via get_pool_strategy.
- hermes_cli/config: memoize ensure_hermes_home() per home path. It
  runs inside the config lock on EVERY load_config(), paying ~14
  mkdir/chmod syscalls per call. The fast path still re-checks that the
  home dir exists, so a deleted home is recreated as before; profile
  switches hit the new path and re-run. Tests cover both.
- tui_gateway/server: add model.options to _LONG_HANDLERS. It measured
  seconds inline on the WS reader thread — while it ran, prompt.submit
  and session.interrupt sat unread (same class as #21123).

Together: model.options RPC 4825/1842ms → 426/230ms (measured on the
live desktop backend); build_models_payload in isolation 6.2s → 0.97s
cold, 0.27s warm.

Desktop — every Radix dialog/popover open forced a whole-document style
recalc (Presence reads getComputedStyle on mount), which on a
1300-message transcript cost ~650-730ms per open (CPU profile:
getAnimationName 483ms self). The worktree dialog (⌘⇧B) paid it on
every single open:

- thread/list: content-visibility:auto + contain-intrinsic-size on the
  per-turn group wrappers. Off-screen turns now skip style recalc,
  layout, and paint entirely; never-rendered turns hold a placeholder
  height (auto: remembered real size once rendered) so scrollbar and
  anchoring stay stable. Verified over CDP: worktree dialog open 656-
  730ms → ~200ms on the same session; stick-to-bottom pin, scroll-to-
  top rendering, and sticky human bubbles all intact.

Also: profile-session-switch harness accepts CDP_HTTP (Chrome tends to
squat on 9222).

Verification:
- scripts/run_tests.sh: config, credential-pool, inventory,
  model-switch routing, tui_gateway protocol, profiles suites green
  (test_profiles has one pre-existing failure on main, unrelated);
  new tests for the ensure_hermes_home memo.
- apps/desktop: tsc clean, eslint/prettier clean, thread + session
  suites green (326 tests).
- E2E over CDP on the live app: numbers above, plus scroll/pin sanity.
This commit is contained in:
Brooklyn Nicholson 2026-07-17 15:01:54 -04:00
parent cf52edbb59
commit 9b8b054c2d
6 changed files with 90 additions and 6 deletions

View file

@ -43,11 +43,19 @@ logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]: def _load_config_safe() -> Optional[dict]:
"""Load config.yaml, returning None on any error.""" """Load config.yaml read-only, returning None on any error.
try:
from hermes_cli.config import load_config
return load_config() Uses ``load_config_readonly()``: every consumer in this module only reads
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
and the deepcopy that ``load_config()`` pays per call is what made
credential-pool checks the dominant cost of ``model.options`` the picker
calls ``load_pool()`` once per provider row, each of which loaded (and
deep-copied) the full config again.
"""
try:
from hermes_cli.config import load_config_readonly
return load_config_readonly()
except Exception: except Exception:
return None return None

View file

@ -10,7 +10,7 @@
import { writeFileSync } from 'node:fs' import { writeFileSync } from 'node:fs'
const CDP_HTTP = 'http://127.0.0.1:9222' const CDP_HTTP = process.env.CDP_HTTP || 'http://127.0.0.1:9222'
const A = process.argv[2] const A = process.argv[2]
const B = process.argv[3] const B = process.argv[3]
const ROUNDS = Number(process.argv[4] || 2) const ROUNDS = Number(process.argv[4] || 2)

View file

@ -360,8 +360,18 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</button> </button>
)} )}
{visibleGroups.map(group => ( {visibleGroups.map(group => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
// reads getComputedStyle) forces a whole-document style recalc,
// measured ~650-730ms per open on a 1300-message session and
// ~100-200ms with this on. contain-intrinsic-size keeps a
// placeholder height for never-rendered turns (auto: remembered
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
<div <div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)" className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap) [contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]"
key={group.id} key={group.id}
> >
<MessageRenderBoundary resetKey={messageSignature}> <MessageRenderBoundary resetKey={messageSignature}>

View file

@ -913,14 +913,32 @@ def _ensure_default_soul_md(home: Path) -> None:
_secure_file(soul_path) _secure_file(soul_path)
# Home paths whose directory skeleton has been created this process — see
# ensure_hermes_home(). Only successful passes are recorded, so a raised
# managed-mode/missing-profile error keeps re-checking on later loads.
_HERMES_HOME_ENSURED: set = set()
def ensure_hermes_home(): def ensure_hermes_home():
"""Ensure ~/.hermes directory structure exists with secure permissions. """Ensure ~/.hermes directory structure exists with secure permissions.
In managed mode (NixOS), dirs are created by the activation script with In managed mode (NixOS), dirs are created by the activation script with
setgid + group-writable (2770). We skip mkdir and set umask(0o007) so setgid + group-writable (2770). We skip mkdir and set umask(0o007) so
any files created (e.g. SOUL.md) are group-writable (0660). any files created (e.g. SOUL.md) are group-writable (0660).
Memoized per home path: this runs on EVERY ``load_config()`` (inside the
config lock), and the ~14 mkdir/chmod syscalls per call made repeated
config loads the dominant cost of hot read paths like ``model.options``.
After the first successful pass for a given ``HERMES_HOME`` we only re-run
the full walk if the home directory itself has vanished (a deleted home is
recreated on the next load, as before). Profile switches change
``get_hermes_home()`` and therefore re-run for the new path.
""" """
home = get_hermes_home() home = get_hermes_home()
key = str(home)
if key in _HERMES_HOME_ENSURED and home.is_dir():
return
# Named profiles must be created explicitly (e.g. ``hermes profile create``). # Named profiles must be created explicitly (e.g. ``hermes profile create``).
# If a stale process keeps running after the profile was renamed/deleted, # If a stale process keeps running after the profile was renamed/deleted,
# silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton # silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton
@ -948,6 +966,8 @@ def ensure_hermes_home():
_secure_dir(d) _secure_dir(d)
_ensure_default_soul_md(home) _ensure_default_soul_md(home)
_HERMES_HOME_ENSURED.add(key)
def _ensure_hermes_home_managed(home: Path): def _ensure_hermes_home_managed(home: Path):
"""Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""

View file

@ -0,0 +1,40 @@
"""ensure_hermes_home is memoized per home path (perf: it runs on every
load_config), but a deleted home must still be recreated on the next call."""
import shutil
from hermes_cli import config as cfg
def test_repeat_calls_are_memoized_but_deleted_home_is_recreated(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
cfg.ensure_hermes_home()
assert (home / "sessions").is_dir()
# Memoized: a second call must not recreate a removed SUBDIR (the fast
# path only re-checks the home root)…
shutil.rmtree(home / "sessions")
cfg.ensure_hermes_home()
assert not (home / "sessions").exists()
# …but a vanished HOME re-runs the full walk and restores the skeleton.
shutil.rmtree(home)
cfg.ensure_hermes_home()
assert (home / "sessions").is_dir()
def test_distinct_home_paths_each_get_the_skeleton(tmp_path, monkeypatch):
first = tmp_path / "a" / ".hermes"
second = tmp_path / "b" / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(first))
cfg.ensure_hermes_home()
# Profile switch: HERMES_HOME moves → the new path is ensured too.
monkeypatch.setenv("HERMES_HOME", str(second))
cfg.ensure_hermes_home()
assert (first / "logs").is_dir()
assert (second / "logs").is_dir()

View file

@ -191,6 +191,12 @@ _LONG_HANDLERS = frozenset(
"complete.path", "complete.path",
"complete.slash", "complete.slash",
"llm.oneshot", "llm.oneshot",
# model.options builds the full picker payload — per-provider credential
# pool checks, pricing fetch, Nous tier check, optional custom-provider
# probe — measured seconds inline. While it runs on the reader thread,
# prompt.submit / session.interrupt sit unread (same class as #21123),
# and the Desktop model pill / picker block on it every open.
"model.options",
# Pet RPCs hit the network (manifest fetch / spritesheet download) or do # Pet RPCs hit the network (manifest fetch / spritesheet download) or do
# per-frame PNG decode/encode (pet.cells): inline they serialize on the # per-frame PNG decode/encode (pet.cells): inline they serialize on the
# reader thread, so picker previews trickle in one at a time and the # reader thread, so picker previews trickle in one at a time and the