fix(compression): refresh gateway activity during compaction

Refresh the agent activity tracker while context compression is blocked in the auxiliary summarizer so gateway watchdogs do not report inactivity during long compactions.

Add regression coverage for successful heartbeats, exception cleanup, touch failures, and strict-signature compressor fallback.

(cherry picked from commit c09e58b7709cc60c5b454701f0ecf840e759222f)
This commit is contained in:
WeiYusc 2026-07-06 00:59:53 +08:00 committed by Teknium
parent ea0fd393db
commit 928bcdde24
3 changed files with 259 additions and 1 deletions

View file

@ -94,6 +94,140 @@ def _count_children(db: SessionDB, parent_sid: str) -> int:
return len(rows)
def _wait_for_touch(touch_calls: list[str], value: str, timeout: float = 1.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if value in touch_calls:
return
time.sleep(0.01)
pytest.fail(f"Timed out waiting for touch activity {value!r}; calls={touch_calls!r}")
def test_compression_activity_heartbeat_touches_agent_during_long_compress(tmp_path: Path) -> None:
"""Long compression must refresh agent activity so gateway watchdogs do not fire."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
def _slow_compress(*_a, **_kw):
_wait_for_touch(touch_calls, "context compression in progress")
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
]
agent.context_compressor.compress.side_effect = _slow_compress
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert touch_calls[0] == "context compression started"
assert "context compression in progress" in touch_calls
assert touch_calls[-1] == "context compression completed"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_stops_on_compress_exception(tmp_path: Path) -> None:
"""Exception paths must stop the heartbeat and release the compression lock."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_FAIL_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
def _failing_compress(*_a, **_kw):
_wait_for_touch(touch_calls, "context compression in progress")
raise RuntimeError("compress boom")
agent.context_compressor.compress.side_effect = _failing_compress
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="compress boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert touch_calls[0] == "context compression started"
assert "context compression in progress" in touch_calls
assert touch_calls[-1] == "context compression failed"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_ignores_touch_errors(tmp_path: Path) -> None:
"""Activity touch failures must not affect compression success semantics."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TOUCH_ERROR_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
agent._touch_activity = lambda _desc: (_ for _ in ()).throw(RuntimeError("touch boom"))
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed[0]["content"] == "[CONTEXT COMPACTION] summary"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_strict_signature_fallback_releases_lock(tmp_path: Path) -> None:
"""Strict compressor signatures still fall back while heartbeat cleanup runs."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TYPEERROR_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = "not-a-number"
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
def _strict_compress(*_args, **kwargs):
if "focus_topic" in kwargs or "force" in kwargs:
raise TypeError("unexpected keyword")
return [
{"role": "user", "content": "[CONTEXT COMPACTION] strict summary"},
{"role": "user", "content": "tail"},
]
agent.context_compressor.compress.side_effect = _strict_compress
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed[0]["content"] == "[CONTEXT COMPACTION] strict summary"
assert touch_calls[0] == "context compression started"
assert touch_calls[-1] == "context compression completed"
assert db.get_compression_lock_holder(session_id) is None
assert agent.context_compressor.compress.call_count == 2
def test_compression_activity_heartbeat_nonfinite_interval_falls_back(tmp_path: Path) -> None:
"""Non-finite heartbeat intervals must not reach Event.wait()."""
from agent.conversation_compression import _CompressionActivityHeartbeat
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_NONFINITE_INTERVAL_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
heartbeat = _CompressionActivityHeartbeat(agent, interval_seconds=float("inf"))
assert heartbeat._interval_seconds == 60.0
heartbeat.start()
heartbeat.stop()
assert touch_calls == ["context compression started", "context compression completed"]
def test_concurrent_compression_does_not_fork_session(tmp_path: Path) -> None:
"""Two AIAgents that share a session_id MUST NOT both rotate it.