260 lines
8.7 KiB
Python
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")
|