gate 5696 on current master
This commit is contained in:
commit
06590556f5
5 files changed
+184
-23
No files matched your search
+72
-2
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user