"""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