Files
hermes-agent/tests/gateway/test_session_hygiene_turnhold_adoption.py
T
Teknium 9de9d7613c fix(compression): keep hygiene turn-hold worker's commit admission so thinking-model summaries are adopted, not burned
The 10s hygiene_max_turn_hold_seconds budget (#92318) releases the arriving
user turn while the summary model is still streaming. For thinking summary
models (DeepSeek-V4-Flash etc.) whose reasoning prefix alone exceeds 10s,
the abandonment path ALWAYS cancelled the commit fence — 100% of the summary
attempt (including the full thinking prefix) was discarded on every turn,
permanently disabling auto-compression while paying the summary model 10s
of thinking per turn, and the flat 60s retry-after then blocked the
agent-side preflight from a fresh chance.

Structural fix (maintainer-chosen direction in #97963): decouple the turn
from the compression instead of holding the turn longer or making the hold
progress-aware (which would reintroduce the #90845 frozen-turn bug):

- CompressionCommitFence gains mark_commit_watermark_fenced() /
  commit_watermark_fenced; compress_context marks the fence right after
  capturing get_active_message_watermark() under the durable compression
  lock (#75316/#87484) — the property that makes a LATE commit safe: rows
  appended after compression start survive both commit paths verbatim as
  cloned concurrent tail (archive_and_compact watermark= and
  publish_compression_child watermark/watermark_ceiling).
- gateway hygiene turn-hold handler: when the fence is watermark-fenced,
  the detached worker (already kept alive via
  _defer_agent_cleanup_until_future_done) KEEPS its commit admission; the
  user's turn proceeds on the uncompressed transcript at the same 10s
  budget, and the summary is adopted at the worker's own watermark-fenced
  commit boundary. Unfenced workers are cancelled exactly as before —
  never worse than the status quo.
- No retry-after is armed while the kept-admission attempt runs (it would
  block preflight adoption via the same-session cooldown); re-attempt
  spacing is covered by the durable compression lock
  (_session_has_compression_in_flight). If the worker ends WITHOUT
  committing, a done-callback restores the flat non-escalating 60s
  retry-after; a successful adoption resets the hygiene failure streak.
  The streak never advances for a deferral either way.
- Docs: configuration.md hygiene_max_turn_hold_seconds one-liner updated
  to describe deferred adoption and the thinking-model case;
  config_defaults.py comment updated. Knob stays config.yaml-only.

Invariants preserved:
- 10s user-latency cap stays hard (#90845/#92318):
  test_session_hygiene_turn_hold_budget_abandons_streaming_wait passes
  UNMODIFIED (its worker is not watermark-fenced, so it pins the cancel
  path through the public surface).
- Stale-clobber impossible: adoption only rides commits bounded by the
  start watermark; the fence still gates admission and unfenced/late
  results are discarded.

New regression tests (tests/gateway/test_session_hygiene_turnhold_adoption.py):
- watermark-fenced worker keeps admission, late summary is committed,
  turn still released at the budget, no cooldown while running,
  streak reset on adoption;
- kept-admission worker that ends without committing restores the flat
  turn-hold retry-after (<=120s, names turn-hold, streak untouched);
- unfenced worker still cancelled and discarded (status quo).
Sabotage-verified: disabling the keep-admission branch fails the two new
adoption tests and leaves the unfenced-cancel test green.

Fixes #97963
2026-09-01 23:56:23 -07:00

432 lines
17 KiB
Python

"""Regression tests for #97963 — hygiene turn-hold must not burn a
watermark-fenced compression attempt.
The 10s ``hygiene_max_turn_hold_seconds`` budget (#92318) releases the
arriving user turn while a thinking summary model is still streaming its
reasoning prefix. Before the fix, that release ALWAYS cancelled the commit
fence, so 100% of the summary attempt (including the full thinking prefix)
was discarded on every turn — auto-compression permanently failed for any
deployment whose summary model thinks longer than the hold.
The fix decouples the turn from the compression: when the worker's commit is
watermark-fenced (rows appended after compression start survive its commit
verbatim as concurrent tail), the detached worker KEEPS its commit admission
and the summary is adopted at its own watermark-fenced commit boundary. The
turn is still released at the same budget — the invariant pinned by
``test_session_hygiene_turn_hold_budget_abandons_streaming_wait`` (#90845)
is untouched (that test's worker is NOT watermark-fenced and still takes the
cancel path).
"""
import asyncio
import importlib
import sys
import threading
import time
import types
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource
def _make_history(n_messages: int, content_size: int = 100) -> list:
history = []
content = "x" * content_size
for i in range(n_messages):
role = "user" if i % 2 == 0 else "assistant"
history.append({"role": role, "content": content, "timestamp": f"t{i}"})
return history
class _CaptureAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(
PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM
)
self.sent = []
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
return None
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.sent.append({"chat_id": chat_id, "content": content})
return SendResult(success=True, message_id="x")
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
def _write_turnhold_config(tmp_path):
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(
"compression:\n"
" enabled: true\n"
" hygiene_timeout_seconds: 60\n"
" hygiene_total_ceiling_seconds: 600\n"
" hygiene_max_turn_hold_seconds: 0.3\n"
" hygiene_failure_cooldown_seconds: 120\n"
)
def _build_runner(gateway_run, adapter, fake_db):
runner = object.__new__(gateway_run.GatewayRunner)
runner.config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")
}
)
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:dm:12345",
session_id="sess-97963",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store.load_transcript.return_value = _make_history(
6, content_size=400
)
runner.session_store.has_any_sessions.return_value = True
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.append_to_transcript = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = SimpleNamespace(_db=fake_db)
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
return runner
def _make_event():
return MessageEvent(
text="hello",
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
user_id="12345",
),
message_id="1",
)
def _install_fakes(monkeypatch, gateway_run, tmp_path, agent_cls):
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = agent_cls
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}
)
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100,
)
async def _drain_deferred(runner, timeout=10.0):
tasks = getattr(runner, "_deferred_agent_cleanup_tasks", None) or set()
if tasks:
await asyncio.wait_for(
asyncio.gather(*list(tasks), return_exceptions=True), timeout
)
@pytest.mark.asyncio
async def test_turn_hold_keeps_admission_and_adopts_watermark_fenced_summary(
monkeypatch, tmp_path
):
"""A watermark-fenced worker keeps its commit admission at turn-hold
expiry; its late summary is ADOPTED (committed), not discarded — while
the turn itself is still released at the budget (#90845 invariant).
"""
worker_started = threading.Event()
release_worker = threading.Event()
committed = threading.Event()
cleanup_done = threading.Event()
fake_db = MagicMock()
fake_db.get_compression_failure_cooldown.return_value = None
class FencedStreamingAgent:
last_instance = None
def __init__(self, **kwargs):
self.session_id = kwargs.get("session_id", "sess-97963")
self._session_db = kwargs.get("session_db")
self._last_compaction_in_place = False
self.context_compressor = SimpleNamespace(
bind_session_state=MagicMock(),
_last_compress_aborted=False,
_last_aux_model_failure_model=None,
)
self.shutdown_memory_provider = MagicMock()
self.close = MagicMock(side_effect=cleanup_done.set)
type(self).last_instance = self
def _compress_context(
self, messages, *_args, commit_fence=None, **_kwargs
):
# Real compress_context marks the fence right after capturing
# the active-row watermark under the durable compression lock.
if commit_fence is not None:
commit_fence.mark_commit_watermark_fenced()
worker_started.set()
# Thinking-model shape: continuous progress, no commit yet —
# only the turn-hold budget can release the waiting turn.
# Bounded spin: a failing assertion before release_worker.set()
# must not leave this executor thread alive forever (pytest
# would hang at interpreter exit joining executor threads).
_spin_started = time.monotonic()
while not release_worker.is_set():
if time.monotonic() - _spin_started > 20:
return (messages, None)
if commit_fence is not None:
commit_fence.touch_progress()
time.sleep(0.01)
if commit_fence is not None and not commit_fence.begin_commit():
return (messages, None)
try:
self._session_db.archive_and_compact(
self.session_id,
[{"role": "assistant", "content": "summary"}],
watermark=6,
)
self._last_compaction_in_place = True
committed.set()
return ([{"role": "assistant", "content": "summary"}], None)
finally:
if commit_fence is not None:
commit_fence.finish_commit()
gateway_run = importlib.import_module("gateway.run")
_write_turnhold_config(tmp_path)
_install_fakes(monkeypatch, gateway_run, tmp_path, FencedStreamingAgent)
adapter = _CaptureAdapter()
runner = _build_runner(gateway_run, adapter, fake_db)
started = time.monotonic()
result = await asyncio.wait_for(runner._handle_message(_make_event()), timeout=15)
elapsed = time.monotonic() - started
# #90845/#92318 invariant intact: the turn is released at the budget.
assert result == "ok"
assert elapsed < 5.0, f"turn held for {elapsed:.1f}s despite the turn-hold budget"
assert worker_started.is_set()
assert runner._run_agent.await_count == 1
# (b) NO retry-after was armed while the attempt is still running —
# arming it would block the agent-side preflight from adopting the
# finished summary ("same-session cooldown active", #97963).
assert not fake_db.record_compression_failure_cooldown.called, (
"keep-admission path must not arm the retry-after while the "
"detached attempt is still running"
)
# The detached worker finishes late; its commit is ADMITTED (adoption),
# not refused — the summary attempt is no longer burned.
release_worker.set()
await asyncio.wait_for(asyncio.to_thread(committed.wait, 5), timeout=6)
assert committed.is_set(), (
"watermark-fenced worker must keep its commit admission after "
"turn-hold expiry (fence was cancelled — attempt burned)"
)
fake_db.archive_and_compact.assert_called_once()
# The commit went through the watermark-fenced path (concurrent tail
# rows above the watermark survive the compaction).
assert fake_db.archive_and_compact.call_args.kwargs.get("watermark") == 6
await _drain_deferred(runner)
await asyncio.wait_for(asyncio.to_thread(cleanup_done.wait, 5), timeout=6)
FencedStreamingAgent.last_instance.close.assert_called_once()
# Successful adoption resets the hygiene failure streak and still never
# advances it (the deferral is not a failure).
assert not fake_db.increment_hygiene_failure_streak.called
assert fake_db.reset_hygiene_failure_streak.called
# Deferral notice still reaches the user.
sent = [m["content"] for m in adapter.sent]
assert any(
"deferred" in c.lower() or "still streaming" in c.lower() for c in sent
), f"turn-hold must send deferral notice, got: {sent}"
@pytest.mark.asyncio
async def test_turn_hold_kept_admission_arms_flat_retry_only_when_nothing_commits(
monkeypatch, tmp_path
):
"""If the kept-admission worker ends WITHOUT committing (summary failed
/ attempt superseded), the flat non-escalating retry-after is restored so
sustained traffic does not spawn-and-abandon a compressor every turn —
but only AFTER the attempt truly ended, and without touching the streak.
"""
worker_started = threading.Event()
release_worker = threading.Event()
fake_db = MagicMock()
fake_db.get_compression_failure_cooldown.return_value = None
class FencedNoCommitAgent:
def __init__(self, **kwargs):
self.session_id = kwargs.get("session_id", "sess-97963")
self._session_db = kwargs.get("session_db")
self._last_compaction_in_place = False
self.context_compressor = SimpleNamespace(
bind_session_state=MagicMock(),
_last_compress_aborted=False,
_last_aux_model_failure_model=None,
)
self.shutdown_memory_provider = MagicMock()
self.close = MagicMock()
def _compress_context(
self, messages, *_args, commit_fence=None, **_kwargs
):
if commit_fence is not None:
commit_fence.mark_commit_watermark_fenced()
worker_started.set()
_spin_started = time.monotonic()
while not release_worker.is_set():
if time.monotonic() - _spin_started > 20:
return (messages, None)
if commit_fence is not None:
commit_fence.touch_progress()
time.sleep(0.01)
# Summary failed — return unchanged, no commit.
return (messages, None)
gateway_run = importlib.import_module("gateway.run")
_write_turnhold_config(tmp_path)
_install_fakes(monkeypatch, gateway_run, tmp_path, FencedNoCommitAgent)
adapter = _CaptureAdapter()
runner = _build_runner(gateway_run, adapter, fake_db)
result = await asyncio.wait_for(runner._handle_message(_make_event()), timeout=15)
assert result == "ok"
assert worker_started.is_set()
# While the attempt still runs: no cooldown, so preflight adoption
# stays possible.
assert not fake_db.record_compression_failure_cooldown.called
release_worker.set()
await _drain_deferred(runner)
# Let the done-callback fire.
for _ in range(100):
if fake_db.record_compression_failure_cooldown.called:
break
await asyncio.sleep(0.05)
# Nothing committed → flat retry-after restored (spacing), streak intact.
assert fake_db.record_compression_failure_cooldown.called, (
"a kept-admission attempt that ends without committing must restore "
"the flat turn-hold retry-after spacing"
)
args = fake_db.record_compression_failure_cooldown.call_args[0]
retry = args[1] - time.time()
assert retry <= 120, (
f"retry-after must stay flat (~60s), got {retry:.0f}s"
)
assert "turn-hold" in (args[2] or "")
assert not fake_db.increment_hygiene_failure_streak.called, (
"turn-hold deferral must never advance the failure streak"
)
@pytest.mark.asyncio
async def test_turn_hold_without_watermark_fence_still_cancels(
monkeypatch, tmp_path
):
"""A worker whose commit is NOT watermark-fenced (no session_db /
watermark capture failed) must still be cancelled at turn-hold expiry —
a late unfenced commit could clobber newer turns. Never worse than the
status quo. (Complements the pinned #90845 test, which exercises the
same path through the public surface.)
"""
worker_started = threading.Event()
release_worker = threading.Event()
fake_db = MagicMock()
fake_db.get_compression_failure_cooldown.return_value = None
class UnfencedStreamingAgent:
def __init__(self, **kwargs):
self.session_id = kwargs.get("session_id", "sess-97963")
self._session_db = kwargs.get("session_db")
self._last_compaction_in_place = False
self.context_compressor = SimpleNamespace(
bind_session_state=MagicMock(),
_last_compress_aborted=False,
_last_aux_model_failure_model=None,
)
self.shutdown_memory_provider = MagicMock()
self.close = MagicMock()
def _compress_context(
self, messages, *_args, commit_fence=None, **_kwargs
):
# Deliberately NO mark_commit_watermark_fenced().
worker_started.set()
_spin_started = time.monotonic()
while not release_worker.is_set():
if time.monotonic() - _spin_started > 20:
return (messages, None)
if commit_fence is not None:
commit_fence.touch_progress()
time.sleep(0.01)
if commit_fence is not None and not commit_fence.begin_commit():
return (messages, None)
try:
self._session_db.archive_and_compact(
self.session_id,
[{"role": "assistant", "content": "too late"}],
)
return ([{"role": "assistant", "content": "too late"}], None)
finally:
if commit_fence is not None:
commit_fence.finish_commit()
gateway_run = importlib.import_module("gateway.run")
_write_turnhold_config(tmp_path)
_install_fakes(monkeypatch, gateway_run, tmp_path, UnfencedStreamingAgent)
adapter = _CaptureAdapter()
runner = _build_runner(gateway_run, adapter, fake_db)
result = await asyncio.wait_for(runner._handle_message(_make_event()), timeout=15)
assert result == "ok"
assert worker_started.is_set()
release_worker.set()
await _drain_deferred(runner)
await asyncio.sleep(0.2)
# The unfenced late commit was refused — discard as before the fix.
fake_db.archive_and_compact.assert_not_called()
# Legacy path still records the flat retry-after immediately.
assert fake_db.record_compression_failure_cooldown.called
assert not fake_db.increment_hygiene_failure_streak.called