Files
hermes-webui/tests/test_xsession_wakeup_misroute.py
T
nesquena-hermes 26e133e3e8 [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

260 lines
12 KiB
Python

"""Regression: cross-session notify_on_complete wakeup misroute (Option 1 + Option 3).
ROOT CAUSE (RCA t_f62ff1e8, verified line-by-line):
WebUI's per-turn session identity was bound ONLY to the process-global
``os.environ['HERMES_SESSION_KEY']`` (streaming.py turn-start), and the env
lock was released BEFORE the agent ran. WebUI NEVER called
``gateway.session_context.set_session_vars`` so the ``_SESSION_KEY``
contextvar stayed ``_UNSET`` and ``tools.approval.get_current_session_key``
fell back to the racy process-global env. Two concurrent WebUI turns
therefore raced on one slot: session A's ``terminal(notify_on_complete=True)``
spawn could capture session B's id, and at completion the server-side wakeup
turn started for the WRONG session.
This module proves BOTH fix layers with REAL modules (no mocks of the code
under test), so each test is RED before the fix and GREEN after — never a
tautology:
Option 1 (root fix, streaming.py) — ``_bind_turn_session_identity`` binds the
REAL ``_SESSION_KEY`` contextvar for the turn's worker thread/context and
clears it on exit. Under a simulated env race across two concurrent turns,
the REAL ``get_current_session_key`` must return each turn's OWN id.
Option 3 (defense-in-depth, background_process.py) —
``_resolve_wakeup_target`` cross-checks the (possibly env-contaminated)
session_key-resolved session against the spawn-time owner persisted in the
process registry's ``ProcessSession.spawn_session_id`` (an env-immune
field). On a positively-detected mismatch it re-routes to the true owner
instead of waking the wrong session.
Precedent for this no-live-server style: tests/test_wakeup_defer_race.py,
tests/test_session_channel_option_x.py.
"""
from __future__ import annotations
import importlib
import threading
import pytest
# ---------------------------------------------------------------------------
# Option 1 — contextvar binding makes per-turn session identity race-immune
# ---------------------------------------------------------------------------
def test_streaming_exposes_turn_session_identity_binder():
"""streaming.py must expose the helper that binds the REAL _SESSION_KEY
contextvar for the agent worker thread (root fix, not env-only)."""
streaming = importlib.import_module("api.streaming")
assert hasattr(streaming, "_bind_turn_session_identity"), (
"Option 1 missing: streaming.py must expose _bind_turn_session_identity "
"to bind gateway.session_context._SESSION_KEY for the turn"
)
def test_concurrent_turns_capture_their_own_session_under_env_race():
"""THE invariant. Two concurrent WebUI turns, A and B. Each binds its own
session identity via the REAL streaming helper, then — while the OTHER
turn has just stamped the shared process-global env (the documented race:
lock released, agent still running) — performs the EXACT capture call a
notify_on_complete spawn makes: ``tools.approval.get_current_session_key``.
Pre-fix: WebUI never set the contextvar, so the helper does not exist
(ImportError above) / the contextvar stays _UNSET and the capture falls
back to the env → A captures B's id → MISROUTE (RED).
Post-fix: the helper binds _SESSION_KEY in each worker context, so the
contextvar (task-local) wins over the racy env → each turn captures its
OWN id (GREEN).
"""
import os
streaming = importlib.import_module("api.streaming")
pytest.importorskip("tools.approval", reason="hermes-agent not installed")
pytest.importorskip("gateway.session_context")
from tools.approval import get_current_session_key
from gateway import session_context as sc
bind = getattr(streaming, "_bind_turn_session_identity", None)
if bind is None:
pytest.fail("Option 1 not implemented: _bind_turn_session_identity missing")
# The two turn() threads below stamp os.environ["HERMES_SESSION_KEY"]
# without owning it. Save/restore the prior value (sentinel for "was
# unset") so this test does not leak state into sibling tests when
# collection order interleaves it with another consumer.
_prev_env_sentinel = object()
_prev_env = os.environ.get("HERMES_SESSION_KEY", _prev_env_sentinel)
SESS_A = "20260518_161627_60b5f4" # board claude-code-import
SESS_B = "47ec28f66dff" # board mcp-optimize
captured: dict[str, str] = {}
barrier = threading.Barrier(2)
# Force a deterministic interleave: A captures only AFTER B has stamped
# the process-global env (reproduces "lock released, agent still running,
# other turn stamps the global slot").
b_stamped_env = threading.Event()
def turn(my_sid: str, label: str) -> None:
# streaming.py turn-start still writes the process-global env as a
# fallback for non-contextvar consumers; the fix is that session-key
# ROUTING now binds the contextvar so it no longer races.
with bind(my_sid):
os.environ["HERMES_SESSION_KEY"] = my_sid
barrier.wait()
if label == "B":
# B stamps env last while A's "agent" is still mid-turn.
os.environ["HERMES_SESSION_KEY"] = my_sid
b_stamped_env.set()
else:
assert b_stamped_env.wait(timeout=5), "B never stamped env"
# The EXACT call terminal_tool.py makes for a bg spawn:
captured[label] = get_current_session_key(default="")
try:
ta = threading.Thread(target=turn, args=(SESS_A, "A"))
tb = threading.Thread(target=turn, args=(SESS_B, "B"))
ta.start()
tb.start()
ta.join(timeout=10)
tb.join(timeout=10)
# Assert the worker threads actually terminated. If a thread deadlocks the
# join() above returns silently — surface that as a clear test failure
# instead of letting downstream asserts mask the hang or leak threads into
# the rest of the run.
assert not ta.is_alive(), "worker thread A did not terminate within join timeout"
assert not tb.is_alive(), "worker thread B did not terminate within join timeout"
# Contextvar must be restored after the turn context exits (no thread-pool
# residue → no new race for a reused worker).
assert sc._SESSION_KEY.get() is sc._UNSET
assert captured.get("A") == SESS_A, (
f"MISROUTE: session A captured {captured.get('A')!r}, expected "
f"{SESS_A!r} — per-turn identity still races on process-global env"
)
assert captured.get("B") == SESS_B, (
f"MISROUTE: session B captured {captured.get('B')!r}, expected {SESS_B!r}"
)
finally:
# Restore HERMES_SESSION_KEY to its pre-test value (or unset if it was
# never set), independent of which assertion above might have failed —
# see save/restore note at the top of the test.
if _prev_env is _prev_env_sentinel:
os.environ.pop("HERMES_SESSION_KEY", None)
else:
assert isinstance(_prev_env, str)
os.environ["HERMES_SESSION_KEY"] = _prev_env
def test_turn_identity_binder_restores_previous_value():
"""Restore uses contextvars reset-token semantics (the canonical idiom),
NOT a blanket clear_session_vars: it composes correctly under nesting and
restores _UNSET for the top-level turn so CLI/cron env-fallback compat is
preserved, and it must NOT touch the platform/chat_id/user session vars
(those keep their env fallback so the notify_on_complete watcher
registration that reads HERMES_SESSION_PLATFORM still works)."""
streaming = importlib.import_module("api.streaming")
pytest.importorskip("tools.approval", reason="hermes-agent not installed")
from tools.approval import get_current_session_key
from gateway import session_context as sc
bind = streaming._bind_turn_session_identity
assert sc._SESSION_KEY.get() is sc._UNSET
# Platform var starts unset → env fallback path intact.
assert sc._SESSION_PLATFORM.get() is sc._UNSET
with bind("sid-outer"):
assert get_current_session_key(default="") == "sid-outer"
with bind("sid-inner"):
assert get_current_session_key(default="") == "sid-inner"
# Reset-token restores the OUTER value (composes under nesting),
# it does NOT clear to "".
assert get_current_session_key(default="") == "sid-outer"
# The binder must never disturb the other session vars.
assert sc._SESSION_PLATFORM.get() is sc._UNSET
# Full exit restores _UNSET → env fallback resumes (CLI/cron compat).
assert sc._SESSION_KEY.get() is sc._UNSET
assert sc._SESSION_PLATFORM.get() is sc._UNSET
# ---------------------------------------------------------------------------
# Option 3 — completion-time second-check against the env-immune spawn owner
# ---------------------------------------------------------------------------
def test_background_process_exposes_wakeup_target_resolver():
bp = importlib.import_module("api.background_process")
assert hasattr(bp, "_resolve_wakeup_target"), (
"Option 3 missing: background_process.py must expose _resolve_wakeup_target "
"to cross-check the session_key-resolved target against the env-immune "
"spawn-time owner"
)
def _fake_ps(session_key: str, spawn_session_id: str):
import types
return types.SimpleNamespace(
session_key=session_key, spawn_session_id=spawn_session_id
)
def test_resolve_wakeup_target_reroutes_on_positive_mismatch(monkeypatch):
"""The RCA scenario: env race made the process's ``session_key`` resolve to
session B, but the env-immune ``spawn_session_id`` says session A truly
spawned it. ``_resolve_wakeup_target`` must detect the positive mismatch
and return A (the true owner), NOT B.
"""
bp = importlib.import_module("api.background_process")
SESS_A = "20260518_161627_60b5f4"
SESS_B = "47ec28f66dff"
# session_key (env-contaminated) → resolves to B; spawn owner is A.
ps = _fake_ps(session_key=SESS_B, spawn_session_id=SESS_A)
resolved = bp._resolve_wakeup_target(
process_id="proc_f377e2f552cd",
session_key_resolved_sid=SESS_B,
proc_session=ps,
)
assert resolved == SESS_A, (
f"Option 3 failed to re-route: woke {resolved!r}, the env-immune spawn "
f"owner is {SESS_A!r} — this is the exact agent.log:6632 misroute"
)
def test_resolve_wakeup_target_passthrough_when_consistent():
"""No mismatch (the normal case, and the post-Option 1 case): the resolver is
a pure pass-through and never suppresses a legitimate Option Z wakeup."""
bp = importlib.import_module("api.background_process")
sid = "session-normal"
ps = _fake_ps(session_key=sid, spawn_session_id=sid)
assert bp._resolve_wakeup_target(
process_id="proc_ok",
session_key_resolved_sid=sid,
proc_session=ps,
) == sid
def test_resolve_wakeup_target_passthrough_when_owner_unknown():
"""If the spawn owner is indeterminate (no env-immune field, e.g. a
cron/CLI process sharing the registry, or a pre-Option 1 spawn), the resolver
must fall through to the session_key-resolved sid UNCHANGED — never
suppress a wakeup on uncertainty (Option Z must keep working)."""
bp = importlib.import_module("api.background_process")
sid = "session-unknown-owner"
assert bp._resolve_wakeup_target(
process_id="proc_cron",
session_key_resolved_sid=sid,
proc_session=_fake_ps(session_key=sid, spawn_session_id=""),
) == sid
assert bp._resolve_wakeup_target(
process_id="proc_cron",
session_key_resolved_sid=sid,
proc_session=None,
) == sid