200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""Regression: out-of-order completion of overlapping ``_compress_context`` calls
|
|
must not corrupt ``_active_compression_commit_fence``.
|
|
|
|
The fence slot used to be a save/restore cell: attempt B saved previous=A and
|
|
published B. When A completed first, its finally popped the slot, deleting B's
|
|
live fence mid-attempt (hard_interrupt lost the handle serializing cancel
|
|
admission against B's begin_commit). B's finally then republished A's dead
|
|
fence, which lingered until the next compression. The slot must instead track
|
|
the newest *live* attempt: a registration stack where each attempt removes only
|
|
its own entry.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from agent import compression_facade
|
|
from agent import conversation_compression
|
|
from agent.conversation_compression import CompressionCommitFence
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
def _build_agent(db: SessionDB, session_id: str):
|
|
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",
|
|
platform="telegram",
|
|
quiet_mode=True,
|
|
session_db=db,
|
|
session_id=session_id,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
compressor = MagicMock()
|
|
compressor.compression_count = 0
|
|
compressor.last_prompt_tokens = 0
|
|
compressor.last_completion_tokens = 0
|
|
compressor._last_summary_error = None
|
|
compressor._last_compress_aborted = False
|
|
compressor._last_summary_auth_failure = False
|
|
compressor._last_aux_model_failure_model = None
|
|
compressor._last_aux_model_failure_error = None
|
|
agent.context_compressor = compressor
|
|
return agent
|
|
|
|
|
|
def _overlapping_attempts(agent, messages):
|
|
"""Drive two _compress_context attempts whose compress_context calls block on
|
|
release events; returns the controls needed to order their completion."""
|
|
fence_a, fence_b = CompressionCommitFence(), CompressionCommitFence()
|
|
entered_a, entered_b = threading.Event(), threading.Event()
|
|
release_a, release_b = threading.Event(), threading.Event()
|
|
errors = []
|
|
|
|
def _fake_compress(_agent, _msgs, _sysmsg, *, commit_fence=None, **_kw):
|
|
if commit_fence is fence_a:
|
|
entered_a.set()
|
|
assert release_a.wait(10), "attempt A never released"
|
|
elif commit_fence is fence_b:
|
|
entered_b.set()
|
|
assert release_b.wait(10), "attempt B never released"
|
|
return [{"role": "user", "content": "x"}], "prompt"
|
|
|
|
def _attempt(fence):
|
|
try:
|
|
agent._compress_context(messages, "sys", commit_fence=fence)
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
|
|
return fence_a, fence_b, entered_a, entered_b, release_a, release_b, errors, _fake_compress, _attempt
|
|
|
|
|
|
class TestCommitFenceCompletionOrder:
|
|
def test_earlier_attempt_finishing_first_keeps_newer_live_fence(
|
|
self, tmp_path: Path, monkeypatch
|
|
):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("S", source="cli")
|
|
agent = _build_agent(db, "S")
|
|
messages = [{"role": "user", "content": "live question"}]
|
|
|
|
(fence_a, fence_b, entered_a, entered_b, release_a, release_b,
|
|
errors, fake, attempt) = _overlapping_attempts(agent, messages)
|
|
monkeypatch.setattr(conversation_compression, "compress_context", fake)
|
|
|
|
thread_a = threading.Thread(target=attempt, args=(fence_a,), daemon=True)
|
|
thread_b = threading.Thread(target=attempt, args=(fence_b,), daemon=True)
|
|
thread_a.start()
|
|
assert entered_a.wait(5)
|
|
thread_b.start()
|
|
assert entered_b.wait(5)
|
|
assert vars(agent)["_active_compression_commit_fence"] is fence_b
|
|
|
|
# A completes while B is still in its try block: B's fence must survive.
|
|
release_a.set()
|
|
thread_a.join(10)
|
|
assert vars(agent).get("_active_compression_commit_fence") is fence_b
|
|
|
|
release_b.set()
|
|
thread_b.join(10)
|
|
assert "_active_compression_commit_fence" not in vars(agent)
|
|
assert not errors
|
|
|
|
def test_lifo_completion_restores_outer_fence_while_it_runs(
|
|
self, tmp_path: Path, monkeypatch
|
|
):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("S", source="cli")
|
|
agent = _build_agent(db, "S")
|
|
messages = [{"role": "user", "content": "live question"}]
|
|
|
|
(fence_a, fence_b, entered_a, entered_b, release_a, release_b,
|
|
errors, fake, attempt) = _overlapping_attempts(agent, messages)
|
|
monkeypatch.setattr(conversation_compression, "compress_context", fake)
|
|
|
|
thread_a = threading.Thread(target=attempt, args=(fence_a,), daemon=True)
|
|
thread_b = threading.Thread(target=attempt, args=(fence_b,), daemon=True)
|
|
thread_a.start()
|
|
assert entered_a.wait(5)
|
|
thread_b.start()
|
|
assert entered_b.wait(5)
|
|
|
|
# B completes first: A's still-running attempt gets its fence back.
|
|
release_b.set()
|
|
thread_b.join(10)
|
|
assert vars(agent).get("_active_compression_commit_fence") is fence_a
|
|
|
|
release_a.set()
|
|
thread_a.join(10)
|
|
assert "_active_compression_commit_fence" not in vars(agent)
|
|
assert not errors
|
|
|
|
|
|
def _capture_new_fence(monkeypatch):
|
|
"""Stub the progress-timeout runner; return the dict that receives its kwargs."""
|
|
captured = {}
|
|
|
|
def _stub_runner(**kw):
|
|
captured.update(kw)
|
|
return (["m"], "p")
|
|
|
|
monkeypatch.setattr(
|
|
conversation_compression, "run_compress_context_with_progress_timeout", _stub_runner)
|
|
monkeypatch.setattr(
|
|
conversation_compression, "request_exceeds_model_window", lambda *a, **k: False)
|
|
return captured
|
|
|
|
|
|
class TestStallFallbackFencePublication:
|
|
"""``_publish_new_fence`` swaps the owning registration's fence and publishes to the
|
|
slot only while that attempt still holds the top registration."""
|
|
|
|
def _drive(self, monkeypatch, agent, registration, fence, lock):
|
|
captured = _capture_new_fence(monkeypatch)
|
|
compression_facade._run_under_progress_timeout(
|
|
agent, lambda *a, **k: (["m"], "p"), [{"role": "user", "content": "x"}], "sys",
|
|
active_fence=fence, registration=registration, fence_registration_lock=lock,
|
|
idle_timeout=1, total_ceiling=2)
|
|
return captured["new_fence"]
|
|
|
|
def test_retry_fence_publishes_while_attempt_owns_slot(self, monkeypatch):
|
|
agent = SimpleNamespace()
|
|
lock = threading.RLock()
|
|
fence = CompressionCommitFence()
|
|
registration = compression_facade._CommitFenceRegistration(fence)
|
|
with lock:
|
|
vars(agent)["_compression_commit_fence_stack"] = [registration]
|
|
agent._active_compression_commit_fence = fence
|
|
|
|
retry = self._drive(monkeypatch, agent, registration, fence, lock)()
|
|
|
|
assert isinstance(retry, CompressionCommitFence)
|
|
assert registration.fence is retry
|
|
assert vars(agent)["_active_compression_commit_fence"] is retry
|
|
|
|
def test_retry_fence_does_not_clobber_newer_attempt(self, monkeypatch):
|
|
agent = SimpleNamespace()
|
|
lock = threading.RLock()
|
|
fence = CompressionCommitFence()
|
|
registration = compression_facade._CommitFenceRegistration(fence)
|
|
other_fence = CompressionCommitFence()
|
|
other = compression_facade._CommitFenceRegistration(other_fence)
|
|
with lock:
|
|
vars(agent)["_compression_commit_fence_stack"] = [registration, other]
|
|
agent._active_compression_commit_fence = other_fence
|
|
|
|
retry = self._drive(monkeypatch, agent, registration, fence, lock)()
|
|
|
|
# The retry still gets its own fresh fence for begin_commit/cancel serialization,
|
|
# but the published slot stays with the newer live attempt.
|
|
assert isinstance(retry, CompressionCommitFence)
|
|
assert registration.fence is retry
|
|
assert vars(agent)["_active_compression_commit_fence"] is other_fence
|