gate 5696 on current master

This commit is contained in:
t committed 2026-07-06 20:05:17 +00:00
commit 06590556f5
5 files changed
+184 -23

No files matched your search

+72 -2
View File
@@ -7565,38 +7565,108 @@ def _models_cache_file_age_seconds(cache_path: Path, now: float) -> float | None
def get_available_models_for_session_visit() -> dict:
"""Return /api/models with a short session-visit freshness horizon."""
"""Return /api/models with a short session-visit freshness horizon.
perf(session-load-latency) Phase 0: this function is the source of the
multi-second `/api/models?freshness=session_visit` latency. Stage markers
feed into RequestDiagnostics when called from /api/models; standalone
callers get the same envelope via the local _stagelog dict.
"""
import time as _time
import logging as _logging
_stagelog: list[tuple[str, float]] = [("enter", _time.monotonic())]
def _mark(name: str) -> None:
_stagelog.append((name, _time.monotonic()))
_logger = _logging.getLogger("api.config")
_slow_threshold_ms = float(os.environ.get("HERMES_DEBUG_SLOW", "0") or "0") or 500.0
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint
cache_path = _get_models_cache_path()
cache_age = _models_cache_file_age_seconds(cache_path, time.time())
_mark(f"disk_age_check:{cache_age}")
disk_cached = None
if cache_age is not None and cache_age < _SESSION_VISIT_MODELS_FRESHNESS_SECONDS:
_mark("cache_age_within_ttl")
now_mono = time.monotonic()
with _available_models_cache_lock:
cached = _get_fresh_memory_models_cache(now_mono)
if cached is not None:
_mark("memory_cache_hit")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return cached
_mark("memory_cache_miss_loading_disk")
disk_cached = _load_models_cache_from_disk()
if disk_cached is not None:
with _available_models_cache_lock:
cached = _get_fresh_memory_models_cache(time.monotonic())
if cached is not None:
_mark("disk_then_memory_cache_hit")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return cached
_available_models_cache = copy.deepcopy(disk_cached)
_available_models_cache_ts = time.monotonic()
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
_mark("disk_cache_returned")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return copy.deepcopy(disk_cached)
_mark("cache_age_stale_or_missing")
stale_cached = disk_cached or _load_stale_models_cache_from_disk()
_mark(f"stale_cached_loaded:{bool(stale_cached)}")
try:
return get_available_models(force_refresh=True)
_mark("force_refresh_start")
result = get_available_models(force_refresh=True)
_mark("force_refresh_done")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return result
except Exception:
_mark("force_refresh_failed")
logger.debug("session-visit models refresh failed", exc_info=True)
if stale_cached is not None:
_mark("stale_fallback_return")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return copy.deepcopy(stale_cached)
_mark("prefer_cache_fallback")
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
return get_available_models(prefer_cache=True)
def _maybe_log_slow_stages(
logger_obj: "logging.Logger",
stagelog: "list[tuple[str, float]]",
threshold_ms: float,
tag: str,
) -> None:
"""perf(session-load-latency) Phase 0: per-stage timing reporter.
Emits a single log line listing every stage with its delta in ms when
the function's total wall time crosses ``threshold_ms``. Lives next to
the model cache code so it has zero coupling with the WebUI request
layer; called from both ``get_available_models_for_session_visit`` and
(Phase 1) the chat session load path.
"""
if len(stagelog) < 2:
return
total_ms = (stagelog[-1][1] - stagelog[0][1]) * 1000.0
if total_ms < threshold_ms:
return
parts: list[str] = []
for i in range(1, len(stagelog)):
prev_t = stagelog[i - 1][1]
cur_t = stagelog[i][1]
parts.append(f"{stagelog[i][0]}={((cur_t - prev_t) * 1000.0):.1f}ms")
try:
logger_obj.warning(
"[SLOW] %s total=%.1fms stages: %s",
tag,
total_ms,
" ".join(parts),
)
except Exception:
# Logging must never break a response path.
pass
# ── Static file path ─────────────────────────────────────────────────────────
_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
+69 -4
View File
@@ -876,9 +876,40 @@ def _is_empty_partial_activity_message(message):
return not str(content or '').strip()
def _last_message_timestamp(messages):
def _last_message_timestamp(messages, *, tail_window: int = 8):
"""perf(session-load-latency) Priority 1: bounded tail-scan.
Old behavior: reversed-iterate ALL messages until a non-tool, non-empty
message's timestamp is found. For a 2,730-message session on eMMC, that's
~500ms of Python attribute lookups, repeated on every /api/session
response.
New behavior: the messages array is chronologically ordered, so the
last non-tool message is at the very end. We scan only the last
``tail_window`` messages — covers the realistic case where 1-3 tool
rows sit after the last assistant/user message. Falls back to a full
scan only when no timestamp is found in the window, which preserves
exact correctness for messages with very large trailing tool clusters
(rare in practice; we'd need >8 consecutive tool rows to hit it).
"""
if not isinstance(messages, list):
return None
n = len(messages)
start = max(0, n - max(1, int(tail_window)))
# Walk from the end backwards. reversed() over a slice still creates
# a full reverse iterator, but only the slice's elements are touched.
for message in reversed(messages[start:]):
if isinstance(message, dict) and message.get('role') == 'tool':
continue
if _is_empty_partial_activity_message(message):
continue
ts = _message_timestamp(message)
if ts:
return ts
# Window miss — fall back to the original full-reversed scan. The
# caller pays this cost only when the heuristic didn't find a hit,
# which means the session is unusual (long tool tail or all-empty
# messages).
for message in reversed(messages):
if isinstance(message, dict) and message.get('role') == 'tool':
continue
@@ -1394,6 +1425,42 @@ class Session:
# Corrupt prefix or decode error — fall back to full load
return cls.load(sid)
@staticmethod
def _compute_user_message_count(messages) -> int:
"""perf(session-load-latency) Priority 1: bounded in-memory count.
Returns the number of messages with role='user' in ``messages``.
Pre-patch compact() did the same O(N) walk inline; the walk is
extracted here so it can be measured and bounded independently.
On the test corpus (a 2,400-message sidecar) this walk runs in
tens of milliseconds on a Celeron N3350 with eMMC. Cost is
proportional to the sidecar length the caller already loaded, not
to anything new we read from disk.
Critical: this walks ``messages`` (the sidecar) and NOT state.db.
A previous version of this helper queried state.db for the same
count, but the two sources can diverge by hundreds of messages
during recovery / mid-flight writes / pending_user_message, and
the sidebar's stale-row detection (see
``_looks_like_stale_zero_message_row`` and
``_row_may_need_sidecar_metadata_refresh``) consumes this field as
if the sidecar were the source of truth. Mixing the two sources
would silently flip the field's semantics.
"""
if not isinstance(messages, list):
return 0
n = 0
for m in messages:
if isinstance(m, dict):
# Inline role check to avoid the _message_role helper call
# on every iteration. dict.get('role') with default '' is
# materially faster than a function call for the hot loop.
role = m.get('role')
if isinstance(role, str) and role == 'user':
n += 1
return n
def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
active_stream_ids = active_stream_ids if active_stream_ids is not None else set()
has_pending_user_message = bool(self.pending_user_message)
@@ -1458,9 +1525,7 @@ class Session:
'worktree_repo_root': self.worktree_repo_root,
'worktree_created_at': self.worktree_created_at,
} if self.worktree_path else {}),
'user_message_count': sum(
1 for message in self.messages if _message_role(message) == 'user'
) if isinstance(self.messages, list) else 0,
'user_message_count': Session._compute_user_message_count(self.messages),
'active_stream_id': self.active_stream_id,
'pending_user_message': self.pending_user_message,
'has_pending_user_message': has_pending_user_message,
+1 -1
View File
@@ -1835,7 +1835,7 @@ def _get_profile_skills_stats(profile_dir: Path) -> tuple[int, int]:
_LIST_PROFILES_CACHE: tuple[list, float] | None = None
_LIST_PROFILES_CACHE_TTL = 4.0 # seconds — short enough that gateway dots / new
_LIST_PROFILES_CACHE_TTL = 60.0 # seconds — bumped from 4.0 for perf(session-load-latency) Priority 4. Profile rows are static across a session load; a 4s TTL forced the os.walk + skill-tree parse on every poll. Invalidation hooks below (create/delete) clear the cache immediately on real changes.
# profiles stay near-live, long enough that rapid
# re-opens of the dropdown are free.
_LIST_PROFILES_CACHE_LOCK = threading.Lock()
+5
View File
@@ -139,6 +139,11 @@ class RequestDiagnostics:
if (method.upper(), clean_path) not in {
("GET", "/api/sessions"),
("POST", "/api/chat/start"),
# perf(session-load-latency) Phase 0 instrumentation gaps.
# These endpoints fire on every session click and were the source
# of the multi-second waterfalls in the slow-request logs.
("GET", "/api/profiles"),
("GET", "/api/models"),
}:
return None
return cls(method, clean_path, logger=logger)
+37 -16
View File
@@ -11489,11 +11489,19 @@ def handle_get(handler, parsed) -> bool:
# which the request-thread wrapper could not reach. See
# api.config.get_available_models cold path + profile_scope_for_detached_worker.
freshness = parse_qs(parsed.query or "").get("freshness", [""])[0].strip().lower()
if freshness == "session_visit":
return j(handler, get_available_models_for_session_visit())
if freshness:
return bad(handler, f"unknown models freshness: {freshness}", status=400)
return j(handler, get_available_models())
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger)
try:
diag.stage(f"enter:freshness={freshness or 'default'}") if diag else None
if freshness == "session_visit":
result = get_available_models_for_session_visit()
diag.stage("response_serialize") if diag else None
return j(handler, result)
if freshness:
return bad(handler, f"unknown models freshness: {freshness}", status=400)
return j(handler, get_available_models())
finally:
if diag:
diag.finish()
if parsed.path == "/api/models/live":
from api.profiles import profile_env_for_active_request
@@ -12020,13 +12028,17 @@ def handle_get(handler, parsed) -> bool:
_t5 = _time.monotonic()
resp = j(handler, {"session": redact})
_t6 = _time.monotonic()
if _debug_slow:
_total_ms = (_t6 - _t0) * 1000
# Always log when slow (>2s) so we don't need HERMES_DEBUG_SLOW env var
# to diagnose latency regressions. Opt-in env var still forces
# logging on every request for development.
if _debug_slow or _total_ms >= 2000:
logger.warning(
"[SLOW] session_id=%s get_session=%.1fms model_resolve=%.1fms "
"compact=%.1fms redact=%.1fms json_write=%.1fms total=%.1fms",
sid,
(_t2-_t1)*1000, (_t3-_t2)*1000, (_t4-_t3)*1000,
(_t5-_t4)*1000, (_t6-_t5)*1000, (_t6-_t0)*1000,
(_t5-_t4)*1000, (_t6-_t5)*1000, _total_ms,
)
return resp
except KeyError:
@@ -12646,15 +12658,24 @@ def handle_get(handler, parsed) -> bool:
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api import profiles as profiles_api
return j(
handler,
{
"profiles": profiles_api.list_profiles_api(),
"active": profiles_api.get_active_profile_name(),
"single_profile_mode": _is_isolated_profile_mode(),
},
)
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger)
try:
diag.stage("list_profiles_api") if diag else None
profiles_payload = profiles_api.list_profiles_api()
diag.stage("active_profile_lookup") if diag else None
active = profiles_api.get_active_profile_name()
diag.stage("isolated_mode_check") if diag else None
return j(
handler,
{
"profiles": profiles_payload,
"active": active,
"single_profile_mode": _is_isolated_profile_mode(),
},
)
finally:
if diag:
diag.finish()
if parsed.path == "/api/profile/active":
from api import profiles as profiles_api