432 lines
17 KiB
Python
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
|