173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
"""Regression tests for #88197 — a dirty automatic ``ended_at`` stamp on a
|
|
live compression parent must not wedge rotation.
|
|
|
|
TUI server shutdown (``_shutdown_sessions``) stamps ``ended_at`` /
|
|
``end_reason='tui_shutdown'`` on every session in its memory, including
|
|
sessions whose agent process keeps running (dist auto-reload, second TUI
|
|
instance). Nothing on the attach path clears it, so every subsequent rotation
|
|
aborted at ``publish_compression_child``'s liveness check — silently and
|
|
forever (#88197: 7 aborted attempts, 88% duplicate rows, HTTP 400; the
|
|
amplification half was fixed by #88411, the wedge half is covered here).
|
|
|
|
Contract under test: automatic-cleanup stamps (``is_automatic_end_reason``)
|
|
are stale-by-construction for a writer that holds the compression lease —
|
|
``publish_compression_child`` clears them in its own transaction and
|
|
proceeds; deliberate boundaries (``compression``, ``session_reset``,
|
|
explicit close) still fail closed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_state import SessionDB
|
|
from hermes_state_common import is_automatic_end_reason
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path: Path):
|
|
handle = SessionDB(db_path=tmp_path / "state.db")
|
|
try:
|
|
yield handle
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def _publish(db: SessionDB, parent: str, child: str) -> None:
|
|
db.publish_compression_child(
|
|
parent_session_id=parent,
|
|
child_session_id=child,
|
|
source="tui",
|
|
messages=[{"role": "user", "content": "[CONTEXT COMPACTION] summary"}],
|
|
require_compression_lease=False,
|
|
)
|
|
|
|
|
|
class TestAutomaticEndReasonPredicate:
|
|
def test_taxonomy(self) -> None:
|
|
for reason in (
|
|
"tui_shutdown",
|
|
"ws_disconnect",
|
|
"idle_timeout",
|
|
"lru_evict",
|
|
"ws_orphan_reap",
|
|
"agent_close",
|
|
"startup_orphan_reap",
|
|
"superseded_by_resume",
|
|
):
|
|
assert is_automatic_end_reason(reason), reason
|
|
for reason in (
|
|
"compression",
|
|
"session_reset",
|
|
"session_switch",
|
|
"tui_close",
|
|
None,
|
|
"",
|
|
):
|
|
assert not is_automatic_end_reason(reason), reason
|
|
|
|
|
|
class TestPublishHealsAutomaticStamp:
|
|
@pytest.mark.parametrize(
|
|
"reason", ["tui_shutdown", "ws_disconnect", "ws_orphan_reap", "idle_timeout"]
|
|
)
|
|
def test_rotation_publishes_through_automatic_stamp(
|
|
self, db: SessionDB, reason: str
|
|
) -> None:
|
|
parent = f"P_{reason}"
|
|
db.create_session(parent, source="tui")
|
|
db.append_message(parent, "user", content="hello")
|
|
db.end_session(parent, reason) # the dirty stamp
|
|
assert db.get_session(parent)["ended_at"] is not None
|
|
|
|
_publish(db, parent, f"C_{reason}")
|
|
|
|
parent_row = db.get_session(parent)
|
|
# Parent closed with its TRUE boundary, not the stale stamp.
|
|
assert parent_row["end_reason"] == "compression"
|
|
assert parent_row["ended_at"] is not None
|
|
child_row = db.get_session(f"C_{reason}")
|
|
assert child_row is not None
|
|
assert child_row["parent_session_id"] == parent
|
|
|
|
@pytest.mark.parametrize("reason", ["compression", "session_reset", "tui_close"])
|
|
def test_deliberate_boundary_still_fails_closed(
|
|
self, db: SessionDB, reason: str
|
|
) -> None:
|
|
parent = f"P_{reason}"
|
|
db.create_session(parent, source="tui")
|
|
db.end_session(parent, reason)
|
|
with pytest.raises(RuntimeError, match="already ended"):
|
|
_publish(db, parent, f"C_{reason}")
|
|
# No child row leaked from the refused publish.
|
|
assert db.get_session(f"C_{reason}") is None
|
|
|
|
def test_live_parent_unaffected(self, db: SessionDB) -> None:
|
|
db.create_session("P_live", source="tui")
|
|
_publish(db, "P_live", "C_live")
|
|
assert db.get_session("P_live")["end_reason"] == "compression"
|
|
assert db.get_session("C_live") is not None
|
|
|
|
|
|
class TestRotationEndToEnd:
|
|
def _build_agent(self, 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="tui",
|
|
quiet_mode=True,
|
|
session_db=db,
|
|
session_id=session_id,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
compressor = MagicMock()
|
|
compressor.compress.return_value = [
|
|
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
|
{"role": "user", "content": "tail"},
|
|
]
|
|
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_summary_auth_failure = False
|
|
compressor._last_aux_model_failure_model = None
|
|
compressor._last_aux_model_failure_error = None
|
|
agent.context_compressor = compressor
|
|
agent.compression_in_place = False # rotation path
|
|
return agent
|
|
|
|
def test_dirty_tui_shutdown_stamp_does_not_wedge_rotation(
|
|
self, db: SessionDB
|
|
) -> None:
|
|
"""#88197 end-to-end: TUI shutdown stamps the live session, then
|
|
auto-compaction rotates anyway instead of aborting forever."""
|
|
parent = "PARENT_88197_E2E"
|
|
db.create_session(parent, source="tui")
|
|
agent = self._build_agent(db, parent)
|
|
|
|
# Simulate the old TUI server exit stamping the still-live session.
|
|
db.end_session(parent, "tui_shutdown")
|
|
|
|
msgs = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
|
agent._compress_context(list(msgs), "sys", approx_tokens=120_000)
|
|
|
|
assert agent.session_id != parent, (
|
|
"rotation aborted on the stale tui_shutdown stamp — "
|
|
"the #88197 wedge is back"
|
|
)
|
|
parent_row = db.get_session(parent)
|
|
assert parent_row["end_reason"] == "compression"
|
|
child_row = db.get_session(agent.session_id)
|
|
assert child_row is not None
|
|
assert child_row["parent_session_id"] == parent
|