mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
293 lines
9.3 KiB
Python
293 lines
9.3 KiB
Python
"""POSIX advisory locks must survive Hermes' own database inspection.
|
|
|
|
close() on ANY file descriptor for a SQLite database cancels every POSIX
|
|
advisory lock the process holds on that file -- including a running VACUUM's
|
|
EXCLUSIVE lock and an in-flight BEGIN IMMEDIATE's RESERVED lock:
|
|
|
|
https://sqlite.org/howtocorrupt.html#_posix_advisory_locks_canceled_by_a_separate_thread_doing_close_
|
|
|
|
Hermes used to byte-probe live databases in several places (kanban's
|
|
post-commit page-count check, the zeroed-state.db detector run on every
|
|
SessionDB construction, backup header verification). Under `hermes sessions
|
|
optimize` this let an external process write into a database while VACUUM was
|
|
rewriting it, producing "database disk image is malformed".
|
|
|
|
These tests pin the behavioural contract: an external process must stay locked
|
|
out across Hermes' inspection calls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.sqlite_safe_read import (
|
|
file_length_matches_header,
|
|
has_live_connection,
|
|
page_count_bytes,
|
|
read_header_bytes_preopen,
|
|
track_connection,
|
|
untrack_connection,
|
|
)
|
|
|
|
|
|
_INTRUDER = textwrap.dedent(
|
|
"""
|
|
import sqlite3, sys
|
|
conn = sqlite3.connect(sys.argv[1], isolation_level=None, timeout=0)
|
|
try:
|
|
conn.execute("PRAGMA busy_timeout=0")
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute("INSERT INTO t(v) VALUES ('intruder')")
|
|
conn.execute("COMMIT")
|
|
print("ACQUIRED")
|
|
except sqlite3.OperationalError:
|
|
print("BLOCKED")
|
|
"""
|
|
)
|
|
|
|
|
|
def _external_writer_can_break_in(db_path) -> bool:
|
|
"""True when a separate process managed to write to a locked database."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", _INTRUDER, str(db_path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
return "ACQUIRED" in result.stdout
|
|
|
|
|
|
def _make_db(path, journal_mode: str) -> None:
|
|
conn = sqlite3.connect(str(path), isolation_level=None)
|
|
conn.execute(f"PRAGMA journal_mode={journal_mode}")
|
|
conn.execute("CREATE TABLE t(v TEXT)")
|
|
conn.executemany("INSERT INTO t(v) VALUES (?)", [(f"row{i}",) for i in range(200)])
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_registry():
|
|
yield
|
|
# Keep the module-level registry from leaking across tests.
|
|
import hermes_cli.sqlite_safe_read as mod
|
|
|
|
with mod._live_lock:
|
|
mod._live_connections.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_preopen_read_refused_while_connection_is_live(tmp_path, clean_registry):
|
|
"""The byte-level probe is allowed pre-open and refused once connected."""
|
|
db = tmp_path / "state.db"
|
|
_make_db(db, "WAL")
|
|
|
|
assert not has_live_connection(db)
|
|
head = read_header_bytes_preopen(db, length=16)
|
|
assert head == b"SQLite format 3\x00"
|
|
|
|
track_connection(db)
|
|
try:
|
|
assert has_live_connection(db)
|
|
assert read_header_bytes_preopen(db, length=16) is None
|
|
# An explicit override stays available for offline artifacts.
|
|
assert read_header_bytes_preopen(db, length=16, force=True) is not None
|
|
finally:
|
|
untrack_connection(db)
|
|
|
|
assert not has_live_connection(db)
|
|
assert read_header_bytes_preopen(db, length=16) is not None
|
|
|
|
|
|
def test_tracking_registry_does_not_leak_across_close_paths(tmp_path, clean_registry):
|
|
"""A drifting counter would silently disable the probe guard forever.
|
|
|
|
Opens are easy to count; closes happen in many places. If the registry
|
|
ever over-counts, ``has_live_connection`` stays true for a path with no
|
|
live connection and every later byte-probe is refused — turning the
|
|
safety guard into a permanent outage of zeroed-file / header detection.
|
|
"""
|
|
import contextlib
|
|
|
|
from hermes_cli.sqlite_safe_read import connect_tracked
|
|
|
|
db = tmp_path / "state.db"
|
|
boot = connect_tracked(db, isolation_level=None)
|
|
boot.execute("CREATE TABLE t(v TEXT)")
|
|
boot.close()
|
|
assert not has_live_connection(db)
|
|
|
|
# plain close
|
|
connect_tracked(db).close()
|
|
assert not has_live_connection(db)
|
|
|
|
# contextlib.closing
|
|
with contextlib.closing(connect_tracked(db)):
|
|
assert has_live_connection(db)
|
|
assert not has_live_connection(db)
|
|
|
|
# `with conn:` is a TRANSACTION scope, not a close — must stay tracked
|
|
conn = connect_tracked(db, isolation_level=None)
|
|
with conn:
|
|
conn.execute("INSERT INTO t(v) VALUES ('x')")
|
|
assert has_live_connection(db), "transaction scope must not untrack"
|
|
conn.close()
|
|
assert not has_live_connection(db)
|
|
|
|
# double close is idempotent (must not under-count into negatives)
|
|
dup = connect_tracked(db)
|
|
dup.close()
|
|
dup.close()
|
|
assert not has_live_connection(db)
|
|
|
|
# nested lifetimes: still live until the last one closes
|
|
first = connect_tracked(db)
|
|
second = connect_tracked(db)
|
|
first.close()
|
|
assert has_live_connection(db)
|
|
second.close()
|
|
assert not has_live_connection(db)
|
|
|
|
# churn must not drift
|
|
for _ in range(100):
|
|
connect_tracked(db).close()
|
|
assert not has_live_connection(db)
|
|
|
|
|
|
def test_probe_and_connect_do_not_race(tmp_path, clean_registry, monkeypatch):
|
|
"""The check and the raw read must be atomic w.r.t. connection lifecycle.
|
|
|
|
Deterministic interleaving: pause the probe *inside* the byte read — after
|
|
it has decided "nothing is live" and opened its descriptor — then let
|
|
another thread open a tracked connection and take a write lock, then let
|
|
the probe close.
|
|
|
|
With the guard holding ``_live_lock`` across check+open+read+close, the
|
|
other thread blocks on that lock until the probe is done, so no
|
|
interleaving is possible. If the lock is only held across the check, that
|
|
thread slips in and the probe's ``close()`` cancels its POSIX locks.
|
|
"""
|
|
import hermes_cli.sqlite_safe_read as ssr
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_db(db, "DELETE")
|
|
|
|
inside_read = threading.Event()
|
|
may_close = threading.Event()
|
|
failures: list[str] = []
|
|
real_open = open
|
|
|
|
def slow_open(*args, **kwargs):
|
|
handle = real_open(*args, **kwargs)
|
|
inside_read.set()
|
|
# Hold the descriptor open while the writer tries to get in.
|
|
may_close.wait(10)
|
|
return handle
|
|
|
|
def writer():
|
|
inside_read.wait(10)
|
|
# If the guard is correct this blocks until the probe releases the
|
|
# lock; if not, it opens and locks inside the probe's window.
|
|
conn = ssr.connect_tracked(db, isolation_level=None, timeout=0.5)
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute("INSERT INTO t(v) VALUES ('holder')")
|
|
may_close.set() # let the probe's close() land
|
|
if _external_writer_can_break_in(db):
|
|
failures.append(
|
|
"external writer broke in: the probe's close() cancelled "
|
|
"this connection's POSIX locks"
|
|
)
|
|
conn.execute("COMMIT")
|
|
except sqlite3.Error as exc: # a cancelled lock surfaces here too
|
|
failures.append(f"holder transaction failed: {exc}")
|
|
finally:
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(ssr, "open", slow_open, raising=False)
|
|
t = threading.Thread(target=writer, daemon=True)
|
|
t.start()
|
|
try:
|
|
read_header_bytes_preopen(db, length=16)
|
|
finally:
|
|
may_close.set() # never wedge the probe if the writer died
|
|
t.join(20)
|
|
|
|
assert not failures, failures[0]
|
|
|
|
|
|
|
|
|
|
def test_session_db_read_only_is_tracked(tmp_path, clean_registry, monkeypatch):
|
|
"""End-to-end: a real read-only SessionDB blocks byte-probes."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from hermes_state import SessionDB
|
|
|
|
db_path = tmp_path / "state.db"
|
|
seed = SessionDB(db_path=db_path)
|
|
seed.create_session("s1", source="cli")
|
|
seed.close()
|
|
|
|
ro = SessionDB(db_path=db_path, read_only=True)
|
|
try:
|
|
assert has_live_connection(db_path)
|
|
assert read_header_bytes_preopen(db_path, length=16) is None
|
|
finally:
|
|
ro.close()
|
|
|
|
assert not has_live_connection(db_path)
|
|
assert read_header_bytes_preopen(db_path, length=16) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_page_count_bytes_matches_on_disk_size(tmp_path):
|
|
"""The PRAGMA route reports the same size the header field encodes."""
|
|
db = tmp_path / "state.db"
|
|
_make_db(db, "DELETE")
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
logical = page_count_bytes(conn)
|
|
assert logical is not None
|
|
assert logical == db.stat().st_size
|
|
assert file_length_matches_header(conn) is True
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_file_length_check_never_reports_truncated_db_as_healthy(tmp_path):
|
|
"""A short file must not come back as a clean 'file length matches'.
|
|
|
|
On a truncated database SQLite refuses the pragma outright, so the helper
|
|
returns None (inconclusive) rather than False. Either way the contract that
|
|
matters is the same: a torn file is never reported as healthy.
|
|
"""
|
|
db = tmp_path / "state.db"
|
|
_make_db(db, "DELETE")
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
logical = page_count_bytes(conn)
|
|
assert logical is not None
|
|
assert file_length_matches_header(conn) is True
|
|
|
|
# Truncate behind SQLite's back to simulate a torn extend.
|
|
with open(db, "r+b") as handle:
|
|
handle.truncate(logical // 2)
|
|
|
|
assert file_length_matches_header(conn) is not True
|
|
finally:
|
|
conn.close()
|