Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user