Files
aiturk-hermes-ide/tests/gateway/test_watchdog_review_76354.py
T

260 lines
8.7 KiB
Python

"""Regressions for #76354 review S1/S2/S4 — activity write budget, watchdog
pre-delivery revalidation, and import/export activity asymmetry.
"""
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from agent.session_activity import ActivityProvenance, build_activity_snapshot
from hermes_state import SessionDB
# ── S1: observational activity writes must not ride the 20s patience ────────
def _hold_write_lock(db_path: Path, held: threading.Event, release: threading.Event):
conn = sqlite3.connect(str(db_path), timeout=30)
try:
conn.execute("BEGIN IMMEDIATE")
held.set()
release.wait(timeout=30)
conn.rollback()
finally:
conn.close()
def test_s1_contended_activity_write_gives_up_within_short_budget(tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
sid = "S1_CONTENDED"
db.create_session(sid, source="cli")
held = threading.Event()
release = threading.Event()
locker = threading.Thread(
target=_hold_write_lock, args=(tmp_path / "state.db", held, release)
)
locker.start()
try:
assert held.wait(timeout=5)
t0 = time.monotonic()
with pytest.raises(sqlite3.OperationalError):
db.touch_session_activity(sid, time.time(), description="working")
elapsed_touch = time.monotonic() - t0
finally:
release.set()
locker.join(timeout=10)
# The observational write gave up within the short budget — far below
# the 20s routine patience the review flagged.
assert elapsed_touch < 3.0, f"activity touch waited {elapsed_touch:.1f}s"
def test_s1_clear_labels_noop_skips_transaction(tmp_path, monkeypatch):
db = SessionDB(db_path=tmp_path / "state.db")
sid = "S1_NOOP"
db.create_session(sid, source="cli")
# Fresh session: labels empty → the clear must not open a transaction.
calls = []
original = db._execute_write
def _spy(fn, patience_s=None):
calls.append(fn)
return original(fn, patience_s=patience_s)
monkeypatch.setattr(db, "_execute_write", _spy)
db.clear_session_activity_labels(sid)
assert calls == [], "no-op label clear must skip the write transaction"
# Non-empty labels → clear runs exactly one write.
db.touch_session_activity(sid, time.time(), description="doing work")
calls.clear()
db.clear_session_activity_labels(sid)
assert len(calls) == 1
activity = db.get_session_activity(sid)
assert activity["last_activity_description"] == ""
def test_s1_contended_clear_gives_up_within_short_budget(tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
sid = "S1_CLEAR_CONTENDED"
db.create_session(sid, source="cli")
db.touch_session_activity(sid, time.time(), description="busy")
held = threading.Event()
release = threading.Event()
locker = threading.Thread(
target=_hold_write_lock, args=(tmp_path / "state.db", held, release)
)
locker.start()
try:
assert held.wait(timeout=5)
t0 = time.monotonic()
with pytest.raises(sqlite3.OperationalError):
db.clear_session_activity_labels(sid)
elapsed = time.monotonic() - t0
finally:
release.set()
locker.join(timeout=10)
assert elapsed < 3.0, f"label clear waited {elapsed:.1f}s under contention"
# ── S2: watchdog revalidates immediately before /new delivery ────────────────
class _FakeAdapter:
def __init__(self):
self._pending_messages = {}
self.sent = []
async def send(self, chat_id, content, metadata=None):
self.sent.append({"chat_id": chat_id, "content": content})
class _RacingAgent:
"""Reports stale activity on the first read, fresh on the second.
Models an agent that makes progress between the watchdog's candidate
scan and its delivery attempt.
"""
def __init__(self):
self.reads = 0
def get_activity_summary(self):
self.reads += 1
age = 999 if self.reads == 1 else 1
return build_activity_snapshot(
last_activity_at=time.time() - age,
last_activity_description="api call",
last_activity_provenance=ActivityProvenance.UNKNOWN,
)
def _runner_for_stall(adapter):
from gateway.run import GatewayRunner
r = GatewayRunner.__new__(GatewayRunner)
r._running = True
r.adapters = {"fake": adapter}
r._profile_adapters = {}
r._running_agents = {}
r._running_agents_ts = {}
r._queued_events = {}
r._session_stall_notified = {}
r._thread_metadata_for_source = lambda source, *a, **k: {}
return r
def _pending_event(chat_id="chat-1"):
source = SimpleNamespace(chat_id=chat_id, thread_id=None, platform=None)
return SimpleNamespace(text="follow-up", source=source, timestamp=time.time())
@pytest.mark.asyncio
async def test_s2_progress_between_scan_and_send_aborts_delivery():
adapter = _FakeAdapter()
runner = _runner_for_stall(adapter)
session_key = "agent:main:telegram:dm:race"
adapter._pending_messages[session_key] = _pending_event()
agent = _RacingAgent()
runner._running_agents[session_key] = agent
sent = await runner._check_session_stalls(60)
# First read said stale; the pre-delivery re-read said fresh → abort.
assert sent == 0
assert adapter.sent == []
assert agent.reads >= 2, "watchdog must re-read activity before delivery"
# Latch re-armed: a future genuine stall must still notify.
assert session_key not in runner._session_stall_notified
@pytest.mark.asyncio
async def test_s2_pending_drained_between_scan_and_send_aborts_delivery():
adapter = _FakeAdapter()
runner = _runner_for_stall(adapter)
session_key = "agent:main:telegram:dm:drain"
class _DrainOnReadAgent:
def __init__(self):
self.reads = 0
def get_activity_summary(self):
self.reads += 1
if self.reads == 1:
# Simulate the queue draining after the candidate scan but
# before the pre-delivery revalidation.
adapter._pending_messages.pop(session_key, None)
return build_activity_snapshot(
last_activity_at=time.time() - 999,
last_activity_description="api call",
last_activity_provenance=ActivityProvenance.UNKNOWN,
)
adapter._pending_messages[session_key] = _pending_event()
runner._running_agents[session_key] = _DrainOnReadAgent()
sent = await runner._check_session_stalls(60)
assert sent == 0
assert adapter.sent == []
@pytest.mark.asyncio
async def test_s2_still_stale_after_revalidation_delivers():
"""Sanity: revalidation must not suppress GENUINE stall notices."""
adapter = _FakeAdapter()
runner = _runner_for_stall(adapter)
session_key = "agent:main:telegram:dm:genuine"
adapter._pending_messages[session_key] = _pending_event()
class _StaleAgent:
def get_activity_summary(self):
return build_activity_snapshot(
last_activity_at=time.time() - 999,
last_activity_description="api call",
last_activity_provenance=ActivityProvenance.UNKNOWN,
)
runner._running_agents[session_key] = _StaleAgent()
sent = await runner._check_session_stalls(60)
assert sent == 1
assert adapter.sent and "/new" in adapter.sent[0]["content"]
# ── S4: export includes activity fields; import resets them ─────────────────
def test_s4_export_includes_activity_import_resets_it(tmp_path):
src = SessionDB(db_path=tmp_path / "src.db")
sid = "S4_PORTABILITY"
src.create_session(sid, source="cli")
src.append_message(sid, "user", "hello")
src.touch_session_activity(
sid,
time.time(),
description="working on something",
provenance=ActivityProvenance.AGENT_COMPRESSION,
)
exported = src.export_session(sid)
# Export INCLUDES the live activity fields (part of the durable row).
assert exported["last_activity_at"] is not None
assert exported["last_activity_description"] == "working on something"
dst = SessionDB(db_path=tmp_path / "dst.db")
result = dst.import_sessions([exported])
assert sid in result.get("imported_ids", result.get("imported", [sid]))
row = dst.get_session(sid)
# Import RESETS activity: no resurrected "working" label on a machine
# where no agent is running (explicit contract, #76354 S4).
assert row.get("last_activity_at") is None
assert not row.get("last_activity_description")
assert not row.get("last_activity_provenance")