Files
hermes-webui/tests/test_wakeup_model_resolve_hang.py
nesquena-hermes 26e133e3e8
Some checks failed
Release & Docker / release (push) Has been cancelled
[HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00

284 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression tests — wakeup-turn model-resolve hang (t_46fadfbc).
Proven root cause (from a live thread-stack capture of a hung wakeup
turn, "Slow WebUI request still running"):
_handle_chat_start → _resolve_compatible_session_model_state
→ get_available_models → _build_available_models_uncached
→ _read_live_provider_model_ids → get_copilot_api_token
→ exchange_copilot_token → urllib HTTPS → BLOCKED
A server-initiated Option-Z wakeup turn (drain thread, idle session, no
browser) reached chat/start with a COLD provider catalog and triggered a
LIVE per-provider rebuild whose Copilot token-exchange HTTPS call hung the
wakeup turn forever on this WSL/corp network — NOT a race.
Two fixes, two tests:
1. test_wakeup_turn_uses_persisted_model_no_live_probe
start_session_turn(source="process_wakeup") resolves the model from the
persisted session record via the cache-only path; the live provider
rebuild (and the Copilot exchange) is never invoked even when the catalog
is cold. The turn still starts with the persisted model.
2. test_chat_start_survives_slow_provider_probe
Defense-in-depth: an unbounded/hanging provider probe cannot stall a
foreground get_available_models() past the wall-clock budget — it falls
back to a usable model list and lets the rebuild finish out-of-band.
"""
from __future__ import annotations
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# Fix 1 — wakeup resolves persisted model with NO live provider probe
# ---------------------------------------------------------------------------
def test_wakeup_turn_uses_persisted_model_no_live_probe(monkeypatch):
"""Cold catalog + a session with a persisted model/provider: a
server-side wakeup must start the turn using the PERSISTED model and must
NOT call the live per-provider rebuild (which is what does the blocking
Copilot token-exchange HTTPS call in the proven thread-stack).
"""
from api import config as cfg
import api.routes as routes
sid = "sess-wakeup-persisted"
persisted_model = "anthropic/claude-sonnet-4"
fake_stream_id = "stream-wakeup-persisted-1"
# HARD ASSERT: the live per-provider probe must never run on the wakeup
# path. _read_live_provider_model_ids is the function the thread-stack
# shows calling get_copilot_api_token → exchange_copilot_token.
def _boom(*_a, **_k):
raise AssertionError(
"live provider rebuild ran on a wakeup turn — "
"_read_live_provider_model_ids must NOT be called (it does the "
"blocking Copilot token-exchange HTTPS call)"
)
monkeypatch.setattr(cfg, "_read_live_provider_model_ids", _boom, raising=True)
# Cold cache: force the cache-miss branch that used to trigger the live
# rebuild. The autouse conftest fixture already invalidates, but be
# explicit so this test documents the precondition.
cfg.invalidate_models_cache()
captured: dict = {}
def _fake_start_chat_stream_for_session(s, **kwargs):
captured["model"] = kwargs.get("model")
captured["model_provider"] = kwargs.get("model_provider")
return {"stream_id": fake_stream_id, "session_id": s.session_id, "_status": 200}
class _FakeSession:
session_id = sid
model = persisted_model
model_provider = "anthropic"
monkeypatch.setattr(
routes, "_start_chat_stream_for_session",
_fake_start_chat_stream_for_session, raising=True,
)
monkeypatch.setattr(routes, "get_session", lambda _sid: _FakeSession(), raising=True)
monkeypatch.setattr(
routes, "_resolve_chat_workspace_with_recovery",
lambda s, w: "/tmp/ws", raising=True,
)
t0 = time.monotonic()
resp = routes.start_session_turn(
sid, "[IMPORTANT: Background process done]", source="process_wakeup"
)
elapsed = time.monotonic() - t0
assert resp.get("stream_id") == fake_stream_id, "wakeup turn did not start"
# The persisted model survives to the turn — never dropped, never blocked.
assert captured["model"] == persisted_model, (
f"wakeup turn used {captured.get('model')!r}, expected the persisted "
f"session model {persisted_model!r}"
)
# Fast: no network. Cache-only resolution is sub-second; allow generous
# slack for slow CI but well under the 10s Copilot timeout that was the
# original symptom.
assert elapsed < 5.0, (
f"wakeup turn took {elapsed:.2f}s — a live provider probe likely ran"
)
def test_wakeup_resolve_passes_prefer_cached_catalog(monkeypatch):
"""White-box: start_session_turn must route model resolution through the
cache-only path (prefer_cached_catalog=True). Pins the wiring so a future
refactor can't silently reintroduce the live-probe hang.
"""
import api.routes as routes
sid = "sess-wakeup-wiring"
seen: dict = {}
real = routes._resolve_compatible_session_model_state
def _spy(model_id, model_provider=None, *, profile_provider=None,
profile_default_model=None, prefer_cached_catalog=False):
seen["prefer_cached_catalog"] = prefer_cached_catalog
# The wakeup path now also threads the session's profile model defaults
# through (greptile fix) so a brand-new session with an empty model
# falls back to the profile default, not the global DEFAULT_MODEL.
seen["profile_provider"] = profile_provider
seen["profile_default_model"] = profile_default_model
return ("m", None, False)
monkeypatch.setattr(
routes, "_resolve_compatible_session_model_state", _spy, raising=True
)
monkeypatch.setattr(
routes, "_start_chat_stream_for_session",
lambda s, **k: {"stream_id": "x", "session_id": s.session_id, "_status": 200},
raising=True,
)
class _FakeSession:
session_id = sid
model = "m"
model_provider = None
monkeypatch.setattr(routes, "get_session", lambda _s: _FakeSession(), raising=True)
monkeypatch.setattr(
routes, "_resolve_chat_workspace_with_recovery",
lambda s, w: "/tmp/ws", raising=True,
)
routes.start_session_turn(sid, "[IMPORTANT: x]", source="process_wakeup")
assert seen.get("prefer_cached_catalog") is True, (
"start_session_turn must resolve the model with "
"prefer_cached_catalog=True so a wakeup never triggers a live probe"
)
assert real is not None # the real function still exists (not deleted)
# ---------------------------------------------------------------------------
# Fix 2 — bounded rebuild: a slow probe cannot stall chat/start forever
# ---------------------------------------------------------------------------
def test_chat_start_survives_slow_provider_probe(monkeypatch):
"""Simulate a hanging provider probe on the NORMAL (prefer_cache=False)
cold path: get_available_models() must return within the wall-clock
budget with a usable fallback instead of blocking on the network.
"""
from api import config as cfg
cfg.invalidate_models_cache()
monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.4, raising=True)
started = {"n": 0}
def _slow_rebuild(_builder):
started["n"] += 1
# >> budget — models the hung Copilot HTTPS call. 0.8s is 2× the
# monkeypatched 0.4s budget, which is the smallest gap that still
# robustly proves the contract while keeping suite wall-clock low
# (was 3.0s; suite-latency cleanup per Copilot review).
time.sleep(0.8)
return {
"active_provider": "anthropic",
"default_model": "anthropic/claude-sonnet-4",
"configured_model_badges": {},
"groups": [],
}
# Replace the rebuild seam so no real per-provider network call happens
# but the foreground still has to wait for (a stand-in for) it.
monkeypatch.setattr(cfg, "_invoke_models_rebuild", _slow_rebuild, raising=True)
# Ensure no disk cache short-circuits the cold path.
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None, raising=True)
t0 = time.monotonic()
result = cfg.get_available_models()
elapsed = time.monotonic() - t0
assert started["n"] == 1, "the rebuild worker should have been started"
assert elapsed < 2.0, (
f"get_available_models() blocked {elapsed:.2f}s on a hung probe — "
f"the {cfg._LIVE_REBUILD_BUDGET_SECONDS}s budget did not bound it"
)
# The fallback must be a structurally valid, usable catalog.
assert isinstance(result, dict)
for k in ("active_provider", "default_model", "configured_model_badges", "groups"):
assert k in result, f"fallback catalog missing {k!r}"
assert isinstance(result["groups"], list)
def test_minimal_static_catalog_is_network_free(monkeypatch):
"""The fallback catalog builder must never reach the live provider probe.
"""
from api import config as cfg
monkeypatch.setattr(
cfg, "_read_live_provider_model_ids",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("minimal catalog must not probe providers")
),
raising=True,
)
out = cfg._minimal_static_models_catalog()
assert set(out) >= {
"active_provider", "default_model", "configured_model_badges", "groups"
}
assert isinstance(out["groups"], list)
def test_prefer_cache_kw_exists_and_skips_live_rebuild(monkeypatch):
"""get_available_models(prefer_cache=True) must resolve without invoking
the rebuild seam at all when the cache is cold.
"""
from api import config as cfg
cfg.invalidate_models_cache()
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None, raising=True)
def _must_not_run(_b):
raise AssertionError(
"prefer_cache=True triggered the live rebuild seam — it must "
"serve cache/minimal-static only"
)
monkeypatch.setattr(cfg, "_invoke_models_rebuild", _must_not_run, raising=True)
result = cfg.get_available_models(prefer_cache=True)
assert isinstance(result, dict)
assert "default_model" in result and "groups" in result
# ---------------------------------------------------------------------------
# Source-grep wiring guards
# ---------------------------------------------------------------------------
def test_get_available_models_has_prefer_cache_param():
import inspect
from api import config as cfg
sig = inspect.signature(cfg.get_available_models)
assert "prefer_cache" in sig.parameters
param = sig.parameters["prefer_cache"]
assert param.kind is inspect.Parameter.KEYWORD_ONLY
assert param.default is False
assert "_LIVE_REBUILD_BUDGET_SECONDS" in cfg.__dict__
assert "_minimal_static_models_catalog" in cfg.__dict__
def test_start_session_turn_uses_cached_catalog():
src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
# The wakeup entrypoint must resolve with the cache-only flag.
i = src.find("def start_session_turn(")
assert i != -1
j = src.find("def _handle_process_complete_ack", i)
body = src[i:j]
assert "prefer_cached_catalog=True" in body, (
"start_session_turn must pass prefer_cached_catalog=True"
)