hermes-agent/tests/gateway/test_compression_concurrent_sessions.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

189 lines
7.5 KiB
Python

"""Behavioral tests for concurrent compression across distinct and shared sessions.
Complements ``test_compression_concurrent_fork.py`` (which tests the
agent-level lock against a real ``SessionDB``) by focusing on gateway-level
isolation guarantees:
1. Five distinct sessions compressing in parallel must not alias each other's
session_ids (no cross-session contamination).
2. Two agents sharing the same session_id must serialize: exactly one rotates,
the other returns its input unchanged (the no-op / lock-loser contract).
The stub-compressor pattern mirrors ``test_compression_concurrent_fork.py``:
the compressor returns deterministic output and sleeps briefly so threads
actually overlap at the OS level, making the absence of aliasing a genuine
stress test rather than a timing accident.
"""
from __future__ import annotations
import os
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _build_agent_with_db(db: SessionDB, session_id: str):
"""Construct an AIAgent wired to *db* and pinned to *session_id*.
Mirrors the helper in test_compression_concurrent_fork.py exactly so the
two test modules can be read side-by-side without cognitive overhead.
"""
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
quiet_mode=True,
session_db=db,
session_id=session_id,
skip_context_files=True,
skip_memory=True,
)
# Stub the compressor: deterministic output, brief sleep to force thread overlap.
compressor = MagicMock()
def _compress_with_overlap(*_a, **_kw):
time.sleep(0.2) # match fork test sleep so threads reliably overlap
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
]
compressor.compress.side_effect = _compress_with_overlap
compressor.compression_count = 1
compressor.last_prompt_tokens = 0
compressor.last_completion_tokens = 0
compressor._last_summary_error = None
compressor._last_compress_aborted = False
compressor._last_aux_model_failure_model = None
compressor._last_aux_model_failure_error = None
agent.context_compressor = compressor
# ROTATION fallback path — pin in_place=False so these keep covering the
# concurrent-rotation lock contract regardless of the global default
# (flipped to True in #38763).
agent.compression_in_place = False
return agent
_MESSAGES = [{"role": "user", "content": f"m{i}"} for i in range(20)]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_concurrent_compressions_same_session_serialize(tmp_path: Path) -> None:
"""Two agents sharing a session_id must not both rotate it.
The per-session compression lock (added in #34351) serializes concurrent
compress() calls keyed on the same session_id. Exactly one agent must
rotate (the lock winner); the other must return its messages unchanged (the
lock loser, which detects ``len(returned) == len(input)`` and backs off).
This is the gateway analogue of the fork test in
``test_compression_concurrent_fork.py`` but scoped to the two-agent /
same-session shape most likely to occur in practice: the main-turn agent
and its background-review fork both hitting the compression threshold.
"""
db = SessionDB(db_path=tmp_path / "state.db")
shared_sid = "SHARED_SESSION_CONCURRENT"
db.create_session(shared_sid, source="discord")
agent_a = _build_agent_with_db(db, shared_sid)
agent_b = _build_agent_with_db(db, shared_sid)
# Force genuine simultaneous lock contention instead of relying on a
# ``time.sleep`` inside the compressor stub to make the threads overlap.
# Under CI CPU starvation that sleep is not enough: one thread could
# acquire → compress → rotate → RELEASE the lock before the other even
# reaches ``try_acquire``, so both would acquire on the shared id and
# both would compress (the historical "got 2" flake). A two-party
# barrier in front of the real acquire guarantees both threads are
# contending for the lock at the same instant, which is exactly the
# condition this test means to assert — with zero timing dependency.
barrier = threading.Barrier(2, timeout=15)
_real_acquire = db.try_acquire_compression_lock
def _barriered_acquire(*args, **kwargs):
# Rendezvous both callers, then let the real (atomic) acquire decide
# the single winner. Tolerate a broken barrier so a test-side timeout
# never masquerades as a lock-logic failure.
try:
barrier.wait()
except threading.BrokenBarrierError:
pass
return _real_acquire(*args, **kwargs)
db.try_acquire_compression_lock = _barriered_acquire
results: dict[str, list | None] = {"a": None, "b": None}
errors: list[Exception] = []
def run(key, agent):
try:
compressed, _sp = agent._compress_context(_MESSAGES, "sys", approx_tokens=120_000)
results[key] = compressed
except Exception as exc:
errors.append(exc)
t_a = threading.Thread(target=run, args=("a", agent_a), name="main_turn")
t_b = threading.Thread(target=run, args=("b", agent_b), name="review_fork")
t_a.start()
t_b.start()
t_a.join(timeout=15)
t_b.join(timeout=15)
# Restore the real method so the post-join lock-leak assertion below
# (and any future call) hits the unwrapped implementation.
db.try_acquire_compression_lock = _real_acquire
assert not errors, f"Compression raised exceptions: {errors}"
# Count which agents actually compressed (returned fewer messages than input)
compressed_count = sum(
1 for msgs in results.values()
if msgs is not None and len(msgs) < len(_MESSAGES)
)
unchanged_count = sum(
1 for msgs in results.values()
if msgs is not None and len(msgs) == len(_MESSAGES)
)
assert compressed_count == 1, (
f"Expected exactly one agent to compress, got {compressed_count}. "
"If both compressed, the lock failed to serialize. "
"If neither compressed, both lost the lock (check lock logic)."
)
assert unchanged_count == 1, (
f"Expected exactly one agent to return messages unchanged (lock loser), "
f"got {unchanged_count}."
)
# Exactly one session_id rotation must have occurred.
rotated = sum(
1 for a in (agent_a, agent_b) if a.session_id != shared_sid
)
assert rotated == 1, (
f"Expected exactly one agent to rotate session_id, got {rotated}. "
"Both agents rotating produces a session fork (Damien's incident shape)."
)
# The lock must be released so future compression on the NEW session_id works.
assert db.get_compression_lock_holder(shared_sid) is None, (
"Compression lock leaked: still held on the parent session_id after both "
"threads joined. Future compression on the child session would deadlock."
)