Files
hermes-webui/tests/manual/repro_wakeup_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

167 lines
5.8 KiB
Python

"""Deterministic BEFORE/AFTER repro for the wakeup model-resolve hang.
Simulates the proven thread-stack: a server-initiated Option-Z wakeup turn
reaches start_session_turn with a COLD provider catalog; the live rebuild's
per-provider probe (the Copilot token-exchange HTTPS call) is monkeypatched to
hang. We drive start_session_turn directly (no browser, no real agent) and
measure how long model-resolution takes.
BEFORE (legacy behaviour: HERMES_WEBUI_MODELS_REBUILD_BUDGET=0 AND
prefer_cache forced off): the wakeup blocks on the hung probe — exactly the
"stuck at resolve_model_provider" symptom.
AFTER (shipped behaviour): start_session_turn resolves with
prefer_cached_catalog=True (never touches the live rebuild) AND the rebuild is
budget-bounded as defense-in-depth -- the wakeup turn starts in well under a
second using the persisted session model.
Run (from the repo root, with the Hermes Agent venv python -- any interpreter
that can import this repo's ``api`` package works):
python tests/manual/repro_wakeup_hang.py
Exits 0 on PASS, 1 on FAIL. Not collected by pytest (see
tests/manual/conftest.py); this is an operator-run reproduction, not a test.
"""
from __future__ import annotations
import os as _os
import sys as _sys
# Repo root is two levels up: <repo>/tests/manual/repro_wakeup_hang.py
_REPO_ROOT = _os.path.dirname(
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
)
if _REPO_ROOT not in _sys.path:
_sys.path.insert(0, _REPO_ROOT)
import sys
import time
HANG_SECONDS = 30.0 # stand-in for an unreachable Copilot endpoint
def _install_fakes():
import api.config as cfg
import api.routes as routes
cfg.invalidate_models_cache()
# Inject the hang at the cold-rebuild seam. This is faithful to the proven
# root cause (the live per-provider rebuild — which on the real host does
# the Copilot token-exchange HTTPS call — is what blocks) and is
# deterministic regardless of which providers the isolated HERMES_HOME has
# configured (an isolated home with no providers can't reach the real
# _read_live_provider_model_ids path; precedent t_9f0184cf).
def _hung_rebuild(_builder):
time.sleep(HANG_SECONDS)
return {
"active_provider": "anthropic",
"default_model": "anthropic/claude-sonnet-4",
"configured_model_badges": {},
"groups": [],
}
cfg._invoke_models_rebuild = _hung_rebuild
# No disk cache → forces the cold rebuild branch.
cfg._load_models_cache_from_disk = lambda: None
fake_stream = "stream-repro-1"
captured = {}
def _fake_start(s, **kwargs):
captured["model"] = kwargs.get("model")
return {"stream_id": fake_stream, "session_id": s.session_id, "_status": 200}
class _FakeSession:
session_id = "sess-repro"
model = "anthropic/claude-sonnet-4"
model_provider = "anthropic"
routes._start_chat_stream_for_session = _fake_start
routes.get_session = lambda _s: _FakeSession()
routes._resolve_chat_workspace_with_recovery = lambda s, w: "/tmp/ws"
return routes, captured, fake_stream
def _time_call(label, fn, timeout):
import threading
box = {}
def _run():
box["resp"] = fn()
t = threading.Thread(target=_run, daemon=True)
t0 = time.monotonic()
t.start()
t.join(timeout=timeout)
elapsed = time.monotonic() - t0
if t.is_alive():
print(f" {label}: STUCK — still blocked after {elapsed:.1f}s "
f"(the wakeup turn never starts; matches the bug symptom)")
return None, elapsed, True
print(f" {label}: started in {elapsed:.3f}s "
f"(model={box.get('resp', {}).get('stream_id')!r})")
return box.get("resp"), elapsed, False
def main():
print("=== BEFORE (legacy: budget=0, prefer_cache forced OFF) ===")
import os
os.environ["HERMES_WEBUI_MODELS_REBUILD_BUDGET"] = "0"
# Reimport config so the budget constant picks up env=0.
for m in ("api.config", "api.routes"):
sys.modules.pop(m, None)
routes, captured, _ = _install_fakes()
# Force the legacy code path: bypass the prefer_cache short-circuit by
# calling resolve with prefer_cached_catalog=False, like the OLD code did.
_orig_resolve = routes._resolve_compatible_session_model_state
routes._resolve_compatible_session_model_state = (
lambda m, p, **_k: _orig_resolve(m, p, prefer_cached_catalog=False)
)
_, _, stuck_before = _time_call(
"wakeup chat/start",
lambda: routes.start_session_turn(
"sess-repro", "[IMPORTANT: bg done]", source="process_wakeup"
),
timeout=8.0,
)
print()
print("=== AFTER (shipped: prefer_cached_catalog=True + bounded rebuild) ===")
os.environ["HERMES_WEBUI_MODELS_REBUILD_BUDGET"] = "4"
for m in ("api.config", "api.routes"):
sys.modules.pop(m, None)
import api.config as cfg2 # noqa: F401
routes2, captured2, fake_stream = _install_fakes()
resp, elapsed_after, stuck_after = _time_call(
"wakeup chat/start",
lambda: routes2.start_session_turn(
"sess-repro", "[IMPORTANT: bg done]", source="process_wakeup"
),
timeout=8.0,
)
print()
print("=== RESULT ===")
ok = (
stuck_before is True
and stuck_after is False
and resp is not None
and resp.get("stream_id") == fake_stream
and captured2.get("model") == "anthropic/claude-sonnet-4"
and elapsed_after < 2.0
)
print(f" BEFORE stuck on hung probe: {stuck_before}")
print(f" AFTER started fast: {not stuck_after} "
f"({elapsed_after:.3f}s, persisted model={captured2.get('model')!r})")
print(f" REPRO {'PASS' if ok else 'FAIL'}")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())