Files
aiturk-hermes-ide/tests/hermes_state/test_88197_automatic_ended_stamp.py

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