hermes-agent/tests/hermes_cli/test_session_recovery.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

602 lines
20 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import hermes_state
from hermes_state import FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB
from hermes_cli import session_recovery
from hermes_cli.session_recovery import (
SessionRecoverySafetyError,
SessionRecoverySourceError,
inspect_session_database,
recover_session_database,
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(64 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _make_source(path: Path) -> dict[str, int]:
db = SessionDB(db_path=path)
try:
for session_number in range(3):
session_id = f"recovery-session-{session_number}"
db.create_session(
session_id,
"cli",
cwd=f"/tmp/recovery-{session_number}",
)
db.set_session_title(session_id, f"Recovery {session_number}")
for message_number in range(7):
db.append_message(
session_id,
"user" if message_number % 2 == 0 else "assistant",
f"recoverable payload {session_number} {message_number}",
)
db.set_meta("goal:recovery-session-0", '{"status":"active"}')
db.apply_telegram_topic_migration()
db._conn.execute(
"""
INSERT INTO telegram_dm_topic_mode (
chat_id, user_id, enabled, activated_at, updated_at
) VALUES (?, ?, 1, ?, ?)
""",
("chat-1", "user-1", 1.0, 2.0),
)
db._conn.execute(
"""
INSERT INTO telegram_dm_topic_bindings (
chat_id, thread_id, user_id, session_key, session_id,
managed_mode, linked_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"chat-1",
"thread-1",
"user-1",
"telegram:user-1:chat-1",
"recovery-session-0",
"auto",
1.0,
2.0,
),
)
db._conn.execute(
"""
INSERT INTO gateway_routing (
scope, session_key, entry_json, updated_at
) VALUES (?, ?, ?, ?)
""",
("telegram", "telegram:user-1:chat-1", "{}", 2.0),
)
db._conn.execute(
"""
INSERT INTO async_delegations (
delegation_id, origin_session, state, dispatched_at, updated_at
) VALUES (?, ?, ?, ?, ?)
""",
("delegation-1", "recovery-session-0", "completed", 1.0, 2.0),
)
# These are derived transition markers and must not reach the new DB.
db.set_meta("fts_rebuild_high_water", "999")
db.set_meta("fts_rebuild_progress", "500")
finally:
db.close()
return {"sessions": 3, "messages": 21}
def _orphan_fts_schema(path: Path) -> None:
conn = sqlite3.connect(str(path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"DELETE FROM sqlite_master "
"WHERE type='table' "
"AND name IN ('messages_fts', 'messages_fts_trigram')"
)
conn.execute("PRAGMA writable_schema=OFF")
finally:
conn.close()
def _make_page_spanning_source(
path: Path,
message_count: int = 320,
) -> tuple[int, int | None]:
db = SessionDB(db_path=path)
try:
db.create_session(
"partial-recovery-session",
"cli",
cwd="/tmp/partial-recovery",
)
for message_number in range(message_count):
db.append_message(
"partial-recovery-session",
"user" if message_number % 2 == 0 else "assistant",
(
f"partial recovery payload {message_number:04d} "
+ chr(65 + message_number % 26) * 1_500
),
)
finally:
db.close()
conn = sqlite3.connect(str(path), isolation_level=None)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("PRAGMA journal_mode=DELETE")
conn.execute("VACUUM")
plan = " ".join(
str(row[3])
for row in conn.execute(
"EXPLAIN QUERY PLAN SELECT COUNT(*) FROM messages"
).fetchall()
)
count_index = next(
(
str(row[0])
for row in conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type = 'index' AND tbl_name = 'messages'"
).fetchall()
if plan.endswith(str(row[0]))
),
None,
)
names = ["messages"]
if count_index is not None:
names.append(count_index)
placeholders = ", ".join("?" for _ in names)
roots = {
str(row[0]): int(row[1])
for row in conn.execute(
"SELECT name, rootpage FROM sqlite_master "
f"WHERE name IN ({placeholders})",
tuple(names),
).fetchall()
}
return roots["messages"], (
roots[count_index] if count_index is not None else None
)
finally:
conn.close()
def _make_many_sessions_source(
path: Path,
session_count: int = 180,
) -> int:
db = SessionDB(db_path=path)
try:
for session_number in range(session_count):
session_id = f"partial-session-{session_number:04d}"
db.create_session(
session_id,
"cli",
cwd=f"/tmp/partial-session-{session_number:04d}",
system_prompt=(
f"session payload {session_number:04d} "
+ chr(65 + session_number % 26) * 1_500
),
)
db.append_message(session_id, "user", f"message {session_number}")
finally:
db.close()
conn = sqlite3.connect(str(path), isolation_level=None)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("PRAGMA journal_mode=DELETE")
conn.execute("VACUUM")
row = conn.execute(
"SELECT rootpage FROM sqlite_master "
"WHERE type = 'table' AND name = 'sessions'"
).fetchone()
assert row is not None
return int(row[0])
finally:
conn.close()
def _btree_leaf_pages(path: Path, root_page: int) -> tuple[int, list[int]]:
data = path.read_bytes()
page_size = int.from_bytes(data[16:18], "big")
if page_size == 1:
page_size = 65_536
leaf_pages: list[int] = []
visited: set[int] = set()
def visit(page_number: int) -> None:
if page_number in visited:
return
visited.add(page_number)
page_start = (page_number - 1) * page_size
header_offset = page_start + (100 if page_number == 1 else 0)
page_type = data[header_offset]
cell_count = int.from_bytes(
data[header_offset + 3 : header_offset + 5],
"big",
)
if page_type in {0x0A, 0x0D}:
leaf_pages.append(page_number)
return
assert page_type in {0x02, 0x05}, (
f"unexpected table b-tree page type {page_type:#x} "
f"on page {page_number}"
)
pointer_array = header_offset + 12
for cell_number in range(cell_count):
pointer_offset = pointer_array + cell_number * 2
cell_offset = int.from_bytes(
data[pointer_offset : pointer_offset + 2],
"big",
)
child_offset = page_start + cell_offset
child_page = int.from_bytes(
data[child_offset : child_offset + 4],
"big",
)
visit(child_page)
rightmost_page = int.from_bytes(
data[header_offset + 8 : header_offset + 12],
"big",
)
visit(rightmost_page)
visit(root_page)
return page_size, leaf_pages
def _corrupt_middle_table_leaf(
path: Path,
root_page: int,
*,
require_interior: bool = True,
) -> int:
page_size, leaf_pages = _btree_leaf_pages(path, root_page)
assert leaf_pages
if require_interior:
assert len(leaf_pages) >= 3
leaf_page = leaf_pages[len(leaf_pages) // 2]
page_start = (leaf_page - 1) * page_size
header_offset = page_start + (100 if leaf_page == 1 else 0)
data = bytearray(path.read_bytes())
assert data[header_offset] in {0x0A, 0x0D}
# An impossible cell count damages this one middle leaf while preserving
# the table root and leaves on both sides. This is a physical SQLite page
# failure, not a mocked cursor exception.
data[header_offset + 3 : header_offset + 5] = b"\xff\xff"
path.write_bytes(data)
return leaf_page
def _corrupt_table_root(path: Path, root_page: int) -> None:
data = bytearray(path.read_bytes())
page_size = int.from_bytes(data[16:18], "big")
if page_size == 1:
page_size = 65_536
page_start = (root_page - 1) * page_size
header_offset = page_start + (100 if root_page == 1 else 0)
assert data[header_offset] in {0x02, 0x05, 0x0A, 0x0D}
# Damage the root enough that no rowid bounds can be read. This reproduces
# a fully failed sessions copy while leaving the messages b-tree intact.
data[header_offset + 3 : header_offset + 5] = b"\xff\xff"
path.write_bytes(data)
def test_snapshot_blocks_connections_opened_during_the_copy(
tmp_path: Path,
) -> None:
"""A connection must not be able to open while raw copy descriptors exist.
Checking has_live_connection() and then copying leaves a window: a
connection can open between the two, and the copy's close() cancels its
POSIX advisory locks. The guard must hold the lifecycle lock across the
whole bundle copy.
Runs the copy in a worker thread and pauses it inside the patched copy, so
the assertion is about lock ordering rather than which thread the
scheduler happens to resume first: while the copy is parked, a
connect_tracked() attempt must NOT complete; once released, it must.
"""
import threading
from hermes_cli import session_recovery as recovery_module
from hermes_cli.sqlite_safe_read import connect_tracked
source = tmp_path / "racy-state.db"
snapshot_dir = tmp_path / "snapshot"
snapshot_dir.mkdir()
_make_source(source)
inside_copy = threading.Event()
release_copy = threading.Event()
connect_attempted = threading.Event()
connection_opened = threading.Event()
errors: list[str] = []
real_copy2 = recovery_module.shutil.copy2
def slow_copy2(src, dst, *args, **kwargs):
result = real_copy2(src, dst, *args, **kwargs)
if str(src).endswith("racy-state.db"):
inside_copy.set()
release_copy.wait(30)
return result
def do_copy():
try:
recovery_module._copy_source_bundle(source, snapshot_dir)
except Exception as exc: # pragma: no cover - surfaced via errors
errors.append(f"copy failed: {exc}")
def do_connect():
# Signal immediately before the blocking call so a timed "still
# blocked" assertion cannot pass merely because this thread had not
# been scheduled yet.
connect_attempted.set()
try:
conn = connect_tracked(source, isolation_level=None, timeout=30.0)
connection_opened.set()
conn.close()
except Exception as exc: # pragma: no cover - surfaced via errors
errors.append(f"connect failed: {exc}")
recovery_module.shutil.copy2 = slow_copy2
copier = threading.Thread(target=do_copy, daemon=True)
connector = threading.Thread(target=do_connect, daemon=True)
try:
copier.start()
assert inside_copy.wait(30), "copy never reached the patched operation"
connector.start()
assert connect_attempted.wait(30), "connector thread never started"
# The connector is at the lock. While the copy holds it, the
# connection must not open.
assert not connection_opened.wait(1.0), (
"connect_tracked() completed while raw copy descriptors were open "
"— the guard is not holding the lifecycle lock across the copy"
)
release_copy.set()
# Once the copy finishes and releases the lock, it must open promptly.
assert connection_opened.wait(30), (
"connect_tracked() never completed after the copy released the lock"
)
finally:
release_copy.set()
recovery_module.shutil.copy2 = real_copy2
copier.join(30)
connector.join(30)
assert not errors, errors[0]
def test_partial_recovery_keeps_messages_when_sessions_are_unsalvageable(
tmp_path: Path,
) -> None:
"""Salvaged messages must survive even when NO session row is recoverable.
Reported July 2026: a user's recovery copied 20,817 of 20,824 messages,
then orphan cleanup deleted every one of them because the sessions b-tree
was damaged worse than the messages b-tree. The output had 0 sessions and
0 messages — the salvage worked and then threw the result away, which is
the exact opposite of what --allow-partial is for.
Messages must be retained under reconstructed placeholder sessions, and
the placeholder-ness must be reported as loss rather than passed off as a
clean recovery.
"""
source = tmp_path / "sessions-destroyed.db"
output = tmp_path / "sessions-destroyed-recovered.db"
messages_per_session = {
"doomed-session-a": 40,
"doomed-session-b": 35,
"doomed-session-c": 45,
}
db = SessionDB(db_path=source)
try:
for session_id, message_count in messages_per_session.items():
db.create_session(session_id, "cli", cwd=f"/tmp/{session_id}")
for index in range(message_count):
db.append_message(
session_id,
"user",
f"irreplaceable {session_id} {index}",
)
finally:
db.close()
# sessions unrecoverable, messages intact — the reported shape.
conn = sqlite3.connect(str(source), isolation_level=None)
try:
conn.execute("DELETE FROM sessions")
finally:
conn.close()
report = recover_session_database(
source,
output,
work_dir=tmp_path,
chunk_size=16,
allow_partial=True,
)
cleanup = report["orphan_cleanup"]
assert cleanup["messages_removed"] == 0, (
"salvaged messages were deleted for lack of a session row"
)
assert cleanup["sessions_reconstructed"] == len(messages_per_session)
assert cleanup["messages_retained"] == 120
with sqlite3.connect(str(output)) as verify:
recovered_sessions = verify.execute(
"SELECT id, source, title, message_count FROM sessions ORDER BY id"
).fetchall()
messages = verify.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
assert messages == 120, f"expected all 120 messages retained, got {messages}"
assert len(recovered_sessions) == len(messages_per_session)
# Fabricated sessions must be identifiable and carry collision-safe titles.
assert {row[0] for row in recovered_sessions} == set(messages_per_session)
assert {row[1] for row in recovered_sessions} == {"recovered"}
recovered_titles = [str(row[2]) for row in recovered_sessions]
assert all(title.startswith("[recovered ") for title in recovered_titles)
assert len(set(recovered_titles)) == len(recovered_titles)
assert {
str(row[0]): int(row[3]) for row in recovered_sessions
} == messages_per_session
# Retaining the data is still a lossy outcome and must say so.
assert report["verification"]["loss_detected"] is True
assert report["partial"] is True
assert report["complete"] is False
assert any(
"reconstructed as placeholders" in warning
for warning in report["verification"]["warnings"]
), report["verification"]["warnings"]
# The output must remain structurally sound.
assert report["verification"]["integrity_check"] == ["ok"]
assert report["verification"]["foreign_key_check"] == []
assert report["verified"] is True
assert report["installed"] is False
def test_cli_allow_partial_salvages_rows_across_a_corrupt_leaf(
tmp_path: Path,
) -> None:
source = tmp_path / "corrupt-state.db"
rejected_output = tmp_path / "rejected.db"
output = tmp_path / "partial-recovered.db"
message_count = 320
messages_root, count_index_root = _make_page_spanning_source(
source,
message_count,
)
corrupt_page = _corrupt_middle_table_leaf(source, messages_root)
if count_index_root is not None:
_corrupt_middle_table_leaf(
source,
count_index_root,
require_interior=False,
)
source_hash = _sha256(source)
inspection = inspect_session_database(source, work_dir=tmp_path)
assert inspection["recoverable"] is False
assert inspection["tables"]["messages"]["rows"] is None
with pytest.raises(SessionRecoverySourceError, match="messages"):
recover_session_database(
source,
rejected_output,
work_dir=tmp_path,
)
assert not rejected_output.exists()
env = os.environ.copy()
env["HERMES_HOME"] = str(tmp_path / "isolated-hermes-home")
result = subprocess.run(
[
sys.executable,
"-m",
"hermes_cli.main",
"sessions",
"recover",
"--source",
str(source),
"--output",
str(output),
"--work-dir",
str(tmp_path),
"--chunk-size",
"8",
"--allow-partial",
],
cwd=Path(__file__).resolve().parents[2],
env=env,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "Partial recovery output verified" in result.stdout
assert "active session database was not changed" in result.stdout
assert _sha256(source) == source_hash
report_path = output.with_name(output.name + ".recovery.json")
report = json.loads(report_path.read_text(encoding="utf-8"))
assert report["allow_partial"] is True
assert report["verified"] is True
assert report["complete"] is False
assert report["partial"] is True
assert report["installed"] is False
assert report["source_unchanged"] is True
assert report["verification"]["healthy"] is True
assert report["verification"]["integrity_check"] == ["ok"]
assert report["verification"]["foreign_key_check"] == []
assert report["verification"]["table_counts"]["sessions"] == 1
copied_messages = report["copy"]["messages"]
assert copied_messages["status"] == "partial"
assert copied_messages["copied_rows"] < message_count
assert copied_messages["copied_rows"] > 0
assert copied_messages["skipped_rowid_ranges"]
assert any(
item["low"] <= message_count and item["high"] >= 1
for item in copied_messages["skipped_rowid_ranges"]
)
assert copied_messages["query_limit_reached"] is False
conn = sqlite3.connect(str(output))
try:
recovered_ids = {
int(row[0]) for row in conn.execute("SELECT id FROM messages")
}
assert 1 in recovered_ids
assert message_count in recovered_ids
assert len(recovered_ids) == copied_messages["copied_rows"]
assert conn.execute("PRAGMA integrity_check").fetchall() == [("ok",)]
finally:
conn.close()
# Prove the helper damaged an interior data leaf, so successful recovery of
# the first and last message IDs really crossed the corrupted region.
assert corrupt_page not in {
min(_btree_leaf_pages(source, messages_root)[1]),
max(_btree_leaf_pages(source, messages_root)[1]),
}