Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,125 @@
"""Regression tests for #94736 — append_message after close() raced a live writer.
Subagent/cron sessions were dying mid-run with ``Session DB append_message
failed: 'NoneType' object has no attribute 'execute'``: a teardown owner
(cron ``run_job``'s ``finally`` block, a delegate timeout owner abandoning
its worker, ``AIAgent.close()``) called ``SessionDB.close()`` — which nulls
``_conn`` — while a still-unwinding worker thread had one more transcript
flush to land. ``_execute_write`` then hit ``None.execute`` and the
conversation loop force-ended the turn as ``session_persistence_failed``,
silently dropping the tail of the session.
The fix self-heals at the shared persistence boundary: when ``_conn`` is
``None`` (only possible after an explicit ``close()``), the writer reopens
a connection to the same database file with a loud WARNING, and the write
lands. These tests exercise the REAL SessionDB against a real sqlite file —
no mocks of the code under test.
"""
import logging
import threading
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
yield d
d.close()
class TestAppendAfterClose:
def test_append_message_after_close_reopens_and_lands(self, db, caplog):
"""The exact #94736 shape: close() then one more transcript flush."""
db.create_session("s1", "cli")
db.append_message("s1", "user", content="hello")
db.close()
assert db._conn is None
with caplog.at_level(logging.WARNING, logger="hermes_state"):
msg_id = db.append_message(
"s1", "assistant", content="flushed after teardown"
)
assert isinstance(msg_id, int) and msg_id > 0
rows = db.get_messages("s1")
assert [r["role"] for r in rows] == ["user", "assistant"]
assert rows[-1]["content"] == "flushed after teardown"
# The recovery is loud, not silent.
assert any("reopening" in r.message for r in caplog.records)
def test_reopened_connection_survives_subsequent_writes_and_close(self, db):
"""The reopened handle is a full writer: more appends work, and a
second close() releases it cleanly (idempotent contract)."""
db.create_session("s1", "cli")
db.close()
db.append_message("s1", "user", content="a")
db.append_message("s1", "assistant", content="b")
assert len(db.get_messages("s1")) == 2
db.close()
assert db._conn is None
db.close() # idempotent
def test_read_after_close_recovers_too(self, db):
"""The locked-read fallback path shares the same guard: a read that
lands after close() must not die on None.execute either."""
db.create_session("s1", "cli")
db.append_message("s1", "user", content="hello")
db.close()
rows = db.get_messages("s1")
assert len(rows) == 1
def test_concurrent_close_during_flush_loses_no_writes(self, tmp_path):
"""Race a teardown close() against a worker mid-flush (the cron
inactivity-timeout shape): every append must land or raise loudly —
never vanish into a swallowed NoneType error."""
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", "cli")
n_writes = 40
start = threading.Event()
errors: list = []
def _worker():
start.wait()
for i in range(n_writes):
try:
db.append_message("s1", "tool", content=f"result {i}")
except Exception as exc: # pragma: no cover - failure path
errors.append(exc)
t = threading.Thread(target=_worker)
t.start()
start.set()
# Teardown owner closes mid-flight, twice for good measure.
db.close()
db.close()
t.join(timeout=30)
assert not t.is_alive()
assert errors == [], f"appends died during teardown race: {errors!r}"
rows = db.get_messages("s1")
assert len(rows) == n_writes
db.close()
def test_read_only_handle_still_refuses_after_close(self, tmp_path):
"""A read-only cross-profile handle must NOT silently reopen — it
raises an explicit error naming the closed handle."""
# Initialise a real DB first with a writable handle.
writer = SessionDB(db_path=tmp_path / "state.db")
writer.create_session("s1", "cli")
writer.append_message("s1", "user", content="hello")
writer.close()
ro = SessionDB(db_path=tmp_path / "state.db", read_only=True)
ro.close()
with pytest.raises(Exception) as excinfo:
ro.get_messages("s1")
assert "closed" in str(excinfo.value).lower()
@@ -0,0 +1,174 @@
"""Tests for RC2: pre-publication lease refresh in publish_compression_child.
When the lease refresher stopped due to transient DB failures, the final
pre-publication refresh inside the same transaction gives one last chance
to extend the lease before the expiry check.
"""
import sqlite3
import threading
import time
from unittest.mock import patch
import pytest
from hermes_state import SessionDB, CompressionSessionBusyError
def _setup_db(tmp_path):
db = SessionDB(tmp_path / "state.db")
return db
def _seed_lock(conn, session_id, holder, expired=False):
now = time.time()
conn.execute(
"INSERT INTO compression_locks (session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)",
(session_id, holder, now, (now - 10.0) if expired else (now + 300.0)),
)
class TestLeaseRefreshBeforePublish:
def test_refresher_stopped_final_refresh_succeeds(self, tmp_path):
db = _setup_db(tmp_path)
db.create_session("parent-1", source="test")
_seed_lock(db._conn, "parent-1", "holder-1", expired=True)
with patch.object(db, "_execute_write", side_effect=lambda fn: fn(db._conn)):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="holder-1",
require_compression_lease=True,
require_lease_refresh=True,
lease_ttl_seconds=300.0,
)
lock = db._conn.execute(
"SELECT expires_at FROM compression_locks WHERE session_id = ?",
("parent-1",),
).fetchone()
assert lock is not None
assert lock[0] > time.time()
parent = db._conn.execute(
"SELECT ended_at FROM sessions WHERE id = ?",
("parent-1",),
).fetchone()
assert parent is not None
assert parent[0] is not None
def test_refresher_stopped_final_refresh_fails_wrong_holder(self, tmp_path):
db = _setup_db(tmp_path)
_seed_lock(db._conn, "parent-1", "other-holder", expired=True)
with patch.object(db, "_execute_write", side_effect=lambda fn: fn(db._conn)):
with pytest.raises(CompressionSessionBusyError, match="lease lost"):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="holder-1",
require_compression_lease=True,
require_lease_refresh=True,
lease_ttl_seconds=300.0,
)
def test_refresher_healthy_no_duplicate_behavior(self, tmp_path):
db = _setup_db(tmp_path)
db.create_session("parent-1", source="test")
now = time.time()
future = now + 300.0
conn = db._conn
conn.execute(
"INSERT INTO compression_locks (session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)",
("parent-1", "holder-1", now, future),
)
with patch.object(db, "_execute_write", side_effect=lambda fn: fn(db._conn)):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="holder-1",
require_compression_lease=True,
require_lease_refresh=True,
lease_ttl_seconds=300.0,
)
lock = conn.execute(
"SELECT expires_at FROM compression_locks WHERE session_id = ?",
("parent-1",),
).fetchone()
assert lock is not None
assert lock[0] >= future
def test_stale_holder_cannot_refresh_and_publish(self, tmp_path):
db = _setup_db(tmp_path)
_seed_lock(db._conn, "parent-1", "new-holder", expired=False)
with patch.object(db, "_execute_write", side_effect=lambda fn: fn(db._conn)):
with pytest.raises(CompressionSessionBusyError, match="lease lost"):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="old-holder",
require_compression_lease=True,
require_lease_refresh=True,
lease_ttl_seconds=300.0,
)
def test_no_refresh_when_require_lease_refresh_false(self, tmp_path):
db = _setup_db(tmp_path)
_seed_lock(db._conn, "parent-1", "holder-1", expired=True)
with patch.object(db, "_execute_write", side_effect=lambda fn: fn(db._conn)):
with pytest.raises(CompressionSessionBusyError, match="lease lost"):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="holder-1",
require_compression_lease=True,
require_lease_refresh=False,
lease_ttl_seconds=300.0,
)
def test_refresh_and_lease_check_are_atomic(self, tmp_path):
db = _setup_db(tmp_path)
db.create_session("parent-1", source="test")
_seed_lock(db._conn, "parent-1", "holder-1", expired=True)
real_execute_write = SessionDB._execute_write
def intercepted_execute_write(self, fn, patience_s=None):
original_fn = fn
def wrapper(conn):
result = original_fn(conn)
lock = conn.execute(
"SELECT expires_at FROM compression_locks WHERE session_id = ?",
("parent-1",),
).fetchone()
assert lock is not None
assert lock[0] > time.time()
return result
return real_execute_write(self, wrapper, patience_s)
with patch.object(SessionDB, "_execute_write", intercepted_execute_write):
db.publish_compression_child(
parent_session_id="parent-1",
child_session_id="child-1",
source="test",
messages=[{"role": "user", "content": "hello"}],
compression_lock_holder="holder-1",
require_compression_lease=True,
require_lease_refresh=True,
lease_ttl_seconds=300.0,
)
@@ -0,0 +1,325 @@
"""Regression tests for stale writes after a compression session split."""
from __future__ import annotations
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
try:
yield session_db
finally:
session_db.close()
def _compression_parent(db: SessionDB, session_id: str = "parent") -> None:
db.create_session(session_id, source="webui")
db.append_message(session_id, "user", "before split")
db.end_session(session_id, "compression")
def test_find_live_compression_child_returns_unique_direct_child(db: SessionDB) -> None:
_compression_parent(db)
db.create_session("child", source="webui", parent_session_id="parent")
child = db.find_live_compression_child("parent")
assert child is not None
assert child["id"] == "child"
assert child["parent_session_id"] == "parent"
assert child["ended_at"] is None
def test_find_live_compression_child_fails_closed_when_ambiguous(db: SessionDB) -> None:
_compression_parent(db)
db.create_session("child-a", source="webui", parent_session_id="parent")
db.create_session("child-b", source="webui", parent_session_id="parent")
assert db.find_live_compression_child("parent") is None
def test_reopen_orphaned_compression_session_reopens_parent_without_child(
db: SessionDB,
) -> None:
_compression_parent(db, "orphan")
assert db.reopen_orphaned_compression_session("orphan") is True
assert db.get_session("orphan")["ended_at"] is None
assert db.get_session("orphan")["end_reason"] is None
db.append_message("orphan", "user", "recovered turn")
assert [m["content"] for m in db.get_messages("orphan")] == [
"before split",
"recovered turn",
]
def test_reopen_orphaned_compression_session_fails_closed_with_child(
db: SessionDB,
) -> None:
_compression_parent(db, "parent-with-child")
db.create_session("child", source="webui", parent_session_id="parent-with-child")
assert db.reopen_orphaned_compression_session("parent-with-child") is False
parent = db.get_session("parent-with-child")
assert parent["end_reason"] == "compression"
assert parent["ended_at"] is not None
def test_reopen_orphaned_compression_session_ignores_non_continuation_children(
db: SessionDB,
) -> None:
_compression_parent(db, "parent-with-non-continuation-children")
db.create_session(
"branch",
source="webui",
parent_session_id="parent-with-non-continuation-children",
model_config={"_branched_from": "parent-with-non-continuation-children"},
)
db.create_session(
"delegate",
source="tool",
parent_session_id="parent-with-non-continuation-children",
model_config={"_delegate_from": "parent-with-non-continuation-children"},
)
assert db.reopen_orphaned_compression_session(
"parent-with-non-continuation-children"
) is True
def test_reopen_fails_closed_when_continuation_inherits_foreign_markers(
db: SessionDB,
) -> None:
"""A REAL continuation can carry ``_delegate_from``/``_branched_from``
pointing at some OTHER session: ``publish_compression_child`` callers
pass the rotated agent's ``_session_init_model_config`` verbatim, so a
delegate subagent's continuation inherits ``_delegate_from=<the
delegate's own parent>``. Marker-presence matching misclassified it as
a delegate child — reopen returned True with a live continuation
present, forking the lineage. Markers only disqualify a child when
they point at the queried parent."""
_compression_parent(db, "delegate-session")
db.create_session(
"delegate-continuation",
source="subagent",
parent_session_id="delegate-session",
model_config={"_delegate_from": "some-original-parent"},
)
assert db.reopen_orphaned_compression_session("delegate-session") is False
parent = db.get_session("delegate-session")
assert parent["end_reason"] == "compression"
def test_find_live_child_returns_continuation_with_foreign_markers(
db: SessionDB,
) -> None:
"""Adoption-side twin of the reopen test above: the continuation that
inherited a foreign ``_delegate_from`` must still be adoptable."""
_compression_parent(db, "delegate-session-2")
db.create_session(
"inherited-continuation",
source="subagent",
parent_session_id="delegate-session-2",
model_config={"_delegate_from": "some-original-parent"},
)
child = db.find_live_compression_child("delegate-session-2")
assert child is not None
assert child["id"] == "inherited-continuation"
def test_compression_lineage_includes_continuation_with_foreign_markers(
db: SessionDB,
) -> None:
"""Lineage walk uses the same parent-bound marker rule as orphan recovery."""
_compression_parent(db, "delegate-session-3")
db.create_session(
"inherited-tip",
source="subagent",
parent_session_id="delegate-session-3",
model_config={"_delegate_from": "some-original-parent"},
)
assert db.get_compression_lineage("inherited-tip") == [
"delegate-session-3",
"inherited-tip",
]
assert db.get_compression_lineage("delegate-session-3") == [
"delegate-session-3",
"inherited-tip",
]
def test_reopen_orphaned_compression_session_fails_closed_with_active_lease(
db: SessionDB,
) -> None:
_compression_parent(db, "leased-parent")
assert db.try_acquire_compression_lock("leased-parent", "compressor")
assert db.reopen_orphaned_compression_session("leased-parent") is False
assert db.get_session("leased-parent")["end_reason"] == "compression"
def test_reopen_orphaned_compression_session_reclaims_expired_lease(
db: SessionDB,
) -> None:
_compression_parent(db, "expired-lease-parent")
now = time.time()
db._conn.execute(
"INSERT INTO compression_locks "
"(session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)",
("expired-lease-parent", "old-compressor", now - 60, now - 30),
)
db._conn.commit()
assert db.reopen_orphaned_compression_session("expired-lease-parent") is True
assert db.refresh_compression_lock(
"expired-lease-parent", "old-compressor"
) is False
assert db.get_compression_lock_holder("expired-lease-parent") is None
def test_reopen_orphaned_compression_session_loses_to_expired_lease_refresh(
db: SessionDB,
) -> None:
_compression_parent(db, "refreshed-lease-parent")
now = time.time()
db._conn.execute(
"INSERT INTO compression_locks "
"(session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)",
("refreshed-lease-parent", "live-compressor", now - 60, now - 30),
)
db._conn.commit()
assert db.refresh_compression_lock(
"refreshed-lease-parent", "live-compressor"
) is True
assert db.reopen_orphaned_compression_session("refreshed-lease-parent") is False
assert db.get_session("refreshed-lease-parent")["end_reason"] == "compression"
def test_find_live_compression_child_ignores_non_continuation_children(
db: SessionDB,
) -> None:
_compression_parent(db)
db.create_session("canonical", source="webui", parent_session_id="parent")
db.create_session(
"branch",
source="webui",
parent_session_id="parent",
model_config={"_branched_from": "parent"},
)
db.create_session(
"delegate",
source="webui",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
db.create_session("tool-child", source="tool", parent_session_id="parent")
child = db.find_live_compression_child("parent")
assert child is not None
assert child["id"] == "canonical"
def test_publish_compression_child_is_atomic_on_handoff_failure(
db: SessionDB, monkeypatch
) -> None:
db.create_session("atomic-parent", source="webui")
db.append_message("atomic-parent", "user", "original")
assert db.try_acquire_compression_lock("atomic-parent", "winner", ttl_seconds=60)
def _boom(*_args, **_kwargs):
raise RuntimeError("handoff insert failed")
monkeypatch.setattr(db, "_insert_message_rows", _boom)
with pytest.raises(RuntimeError, match="handoff insert failed"):
db.publish_compression_child(
parent_session_id="atomic-parent",
child_session_id="atomic-child",
source="webui",
messages=[{"role": "user", "content": "summary"}],
compression_lock_holder="winner",
)
parent = db.get_session("atomic-parent")
assert parent is not None
assert parent["ended_at"] is None
assert db.get_session("atomic-child") is None
def test_publish_compression_child_exposes_complete_child(db: SessionDB) -> None:
db.create_session("atomic-parent", source="webui")
db.append_message("atomic-parent", "user", "original")
assert db.try_acquire_compression_lock("atomic-parent", "winner", ttl_seconds=60)
db.publish_compression_child(
parent_session_id="atomic-parent",
child_session_id="atomic-child",
source="webui",
system_prompt="compressed system",
messages=[{"role": "user", "content": "summary"}],
compression_lock_holder="winner",
)
assert db.get_session("atomic-parent")["end_reason"] == "compression"
child = db.find_live_compression_child("atomic-parent")
assert child is not None
assert child["id"] == "atomic-child"
assert child["system_prompt"] == "compressed system"
assert [m["content"] for m in db.get_messages("atomic-child")] == ["summary"]
def test_publish_compression_child_rejects_lost_or_expired_lease(db: SessionDB) -> None:
db.create_session("lease-parent", source="webui")
db.append_message("lease-parent", "user", "new durable turn")
assert db.try_acquire_compression_lock("lease-parent", "new-winner", ttl_seconds=60)
with pytest.raises(RuntimeError, match="lease lost"):
db.publish_compression_child(
parent_session_id="lease-parent",
child_session_id="stale-child",
source="webui",
messages=[{"role": "user", "content": "stale summary"}],
compression_lock_holder="old-loser",
)
parent = db.get_session("lease-parent")
assert parent is not None
assert parent["ended_at"] is None
assert db.get_session("stale-child") is None
assert [m["content"] for m in db.get_messages("lease-parent")] == [
"new durable turn"
]
def test_compression_lease_blocks_non_owner_but_allows_owner_flush(
db: SessionDB,
) -> None:
"""Contract flipped by the watermark commit (#75316): a live lease no
longer fences ordinary appends — both the owner's flush and a concurrent
turn land immediately, and the commit-side watermark decides what
survives compaction (see test_compression_watermark_commit.py)."""
db.create_session("leased", source="webui")
assert db.try_acquire_compression_lock("leased", "winner", ttl_seconds=60)
db.append_message("leased", "user", "late concurrent turn")
db.append_message(
"leased",
"assistant",
"winner flush",
compression_lock_holder="winner",
)
assert [m["content"] for m in db.get_messages("leased")] == [
"late concurrent turn",
"winner flush",
]
@@ -0,0 +1,120 @@
"""The v25 dedupe migration must degrade gracefully on a contended DB.
Enterprise field report (2026-08-14): with state.db locked by another process,
``_dedupe_legacy_system_prompts`` raised ``sqlite3.OperationalError``
mid-loop (only the initial SELECT was guarded), which aborted schema init,
left the schema version below 25, and made EVERY subsequent
``SessionDB.__init__`` re-enter the same blocking migration — the fuel of
the gateway's watchdog crash loop.
Contract:
- A write failure mid-migration returns gracefully (no raise). Partial
migration is safe by design: the legacy ``system_prompt`` column is kept
as a read fallback for unmigrated rows.
- Rows migrated before the failure stay migrated; unmigrated rows remain
readable and are picked up by a later successful run.
"""
import sqlite3
import pytest
from hermes_state import SessionDB
def _make_legacy_db(tmp_path, n_rows=5):
"""Open a real SessionDB, then regress it to a pre-v25 shape."""
db_path = tmp_path / "state.db"
db = SessionDB(db_path=db_path)
db.close()
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
for i in range(n_rows):
cur.execute(
"INSERT OR IGNORE INTO sessions (id, source, started_at) "
"VALUES (?, 'test', 1.0)",
(f"sess-{i}",),
)
cur.execute(
"UPDATE sessions SET system_prompt = ?, system_prompt_hash = NULL "
"WHERE id = ?",
(f"legacy prompt {i}", f"sess-{i}"),
)
conn.commit()
inserted = cur.execute(
"SELECT COUNT(*) FROM sessions WHERE id LIKE 'sess-%'"
).fetchone()[0]
conn.close()
assert inserted == n_rows, f"fixture only created {inserted}/{n_rows} rows"
return db_path
class _FailAfterN:
"""Cursor proxy: UPDATE statements start failing after N successes."""
def __init__(self, cursor, fail_after):
self._cursor = cursor
self._updates = 0
self._fail_after = fail_after
def execute(self, sql, *args, **kwargs):
if sql.lstrip().upper().startswith("UPDATE"):
if self._updates >= self._fail_after:
raise sqlite3.OperationalError("database is locked")
self._updates += 1
return self._cursor.execute(sql, *args, **kwargs)
def __getattr__(self, name):
return getattr(self._cursor, name)
def test_mid_loop_lock_error_returns_instead_of_raising(tmp_path):
db_path = _make_legacy_db(tmp_path)
db = SessionDB(db_path=db_path)
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
raw = conn.cursor()
proxy = _FailAfterN(raw, fail_after=2)
# Must NOT raise even though the third UPDATE hits "database is locked".
db._dedupe_legacy_system_prompts(proxy)
conn.commit()
rows = raw.execute(
"SELECT id, system_prompt, system_prompt_hash FROM sessions "
"WHERE id LIKE 'sess-%' ORDER BY id"
).fetchall()
migrated = [r for r in rows if r["system_prompt"] is None]
legacy = [r for r in rows if r["system_prompt"] is not None]
assert migrated, "no rows migrated before the simulated lock"
assert legacy, "expected unmigrated remainder after the failure"
# Migrated rows carry a hash; legacy rows keep their readable prompt.
assert all(r["system_prompt_hash"] for r in migrated)
assert all(r["system_prompt"].startswith("legacy prompt") for r in legacy)
conn.close()
finally:
db.close()
def test_later_run_completes_the_remainder(tmp_path):
db_path = _make_legacy_db(tmp_path)
db = SessionDB(db_path=db_path)
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
raw = conn.cursor()
db._dedupe_legacy_system_prompts(_FailAfterN(raw, fail_after=2))
conn.commit()
# Second run with no failures finishes the job.
db._dedupe_legacy_system_prompts(raw)
conn.commit()
remaining = raw.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE id LIKE 'sess-%' AND system_prompt IS NOT NULL"
).fetchone()[0]
assert remaining == 0
conn.close()
finally:
db.close()
+30
View File
@@ -0,0 +1,30 @@
"""is_disk_full_error classifies ENOSPC / SQLITE_FULL failures."""
from __future__ import annotations
import errno
import sqlite3
from hermes_state import is_disk_full_error
def test_enospc_oserror():
assert is_disk_full_error(OSError(errno.ENOSPC, "No space left on device")) is True
def test_sqlite_full_operational_error():
assert is_disk_full_error(sqlite3.OperationalError("database or disk is full")) is True
def test_string_markers():
assert is_disk_full_error("disk full: session storage could not be written") is True
assert is_disk_full_error("ENOSPC writing state.db") is True
assert is_disk_full_error("This is often a full disk — free some space") is True
def test_unrelated_errors():
assert is_disk_full_error(None) is False
assert is_disk_full_error(OSError(errno.EACCES, "Permission denied")) is False
assert is_disk_full_error(RuntimeError("network timeout")) is False
assert is_disk_full_error("session not found") is False
assert is_disk_full_error("session storage could not be written: permission denied") is False
+672
View File
@@ -0,0 +1,672 @@
"""Cross-process admission for full structural FTS rebuilds (PR #93200 class).
Several independent Hermes processes routinely share one state.db (gateway,
Desktop's ``hermes serve`` backend, CLI sessions, the TUI slash worker). Two
of them detecting FTS corruption at once each ran the full FTS5 'rebuild' on
the same file in parallel, colliding on write and structurally corrupting
state.db (two documented production incidents, 2026-08-15 and 2026-08-23).
The fix: every full structural rebuild entry point — ``rebuild_fts()``, the
``_init_schema`` trigger-repair rebuilds, and ``_recover_stale_fts`` — admits
through one cross-process file lock (``fts_rebuild_admission`` in
hermes_state_common) and FAILS CLOSED: a process that cannot acquire the
authority defers the rebuild instead of racing the holder. These tests use
real spawned processes holding the real lock file, per the review contract
on PR #93200 — the bug is cross-process ownership, so monkeypatched helpers
prove nothing.
"""
import contextlib
import errno
import subprocess
import sqlite3
import sys
import time
from pathlib import Path
import pytest
import hermes_state_common
from hermes_state import FTS_STALE_KEY, SessionDB, _FTS_TRIGGERS
pytestmark = pytest.mark.skipif(
sys.platform == "win32", reason="POSIX flock child-process harness"
)
_HOLD_LOCK_SCRIPT = """
import sys, time, fcntl, pathlib
lock_path = pathlib.Path({lock!r})
handle = lock_path.open("a+b")
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
print("locked", flush=True)
time.sleep({hold})
"""
def _lock_file(db_path: Path) -> Path:
return db_path.with_name(db_path.name + ".fts_rebuild.lock")
@contextlib.contextmanager
def _rebuild_lock_held_by_other_process(db_path: Path, hold_seconds: float = 30.0):
"""Hold the FTS rebuild authority for *db_path* in a real child process."""
script = _HOLD_LOCK_SCRIPT.format(
lock=str(_lock_file(db_path)), hold=hold_seconds
)
proc = subprocess.Popen(
[sys.executable, "-c", script], stdout=subprocess.PIPE, text=True
)
try:
assert proc.stdout.readline().strip() == "locked"
yield proc
finally:
proc.kill()
proc.wait(timeout=10)
def _fts_docsize_count(db_path: Path) -> int:
raw = sqlite3.connect(str(db_path))
try:
return raw.execute("SELECT count(*) FROM messages_fts_docsize").fetchone()[0]
finally:
raw.close()
def _base_fts_triggers(db_path: Path) -> set:
raw = sqlite3.connect(str(db_path))
try:
rows = raw.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' "
f"AND name IN ({','.join('?' for _ in _FTS_TRIGGERS)})",
_FTS_TRIGGERS,
).fetchall()
return {r[0] for r in rows}
finally:
raw.close()
def _meta_value(db_path: Path, key: str):
raw = sqlite3.connect(str(db_path))
try:
row = raw.execute(
"SELECT value FROM state_meta WHERE key = ?", (key,)
).fetchone()
return None if row is None else row[0]
finally:
raw.close()
@pytest.fixture
def fast_timeout(monkeypatch):
monkeypatch.setattr(
hermes_state_common, "_FTS_REBUILD_LOCK_TIMEOUT_SECONDS", 0.5
)
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
for i in range(5):
d.append_message("s1", "user", f"hello world {i}")
yield d
try:
d.close()
except Exception:
pass
class TestRebuildFtsAdmission:
def test_rebuild_defers_while_another_process_holds_authority(
self, db, fast_timeout
):
"""Fail closed: the contender must NOT rebuild while the lock is held."""
with _rebuild_lock_held_by_other_process(db.db_path):
assert db.rebuild_fts() == 0
def test_rebuild_proceeds_after_holder_releases(self, db, fast_timeout):
with _rebuild_lock_held_by_other_process(db.db_path):
assert db.rebuild_fts() == 0
# Holder killed on context exit → kernel drops the flock → the next
# caller acquires the authority and the rebuild really runs.
assert db.rebuild_fts() >= 1
def test_rebuild_waits_out_a_short_holder(self, db, monkeypatch):
"""A holder that releases within the bounded wait does not cause deferral."""
monkeypatch.setattr(
hermes_state_common, "_FTS_REBUILD_LOCK_TIMEOUT_SECONDS", 10.0
)
with _rebuild_lock_held_by_other_process(db.db_path, hold_seconds=1.0):
# Child exits after 1s; deadline is 10s — this must acquire and rebuild.
assert db.rebuild_fts() >= 1
def test_admission_yields_true_for_pathless_db(self):
"""In-memory / pathless stores have no cross-process surface."""
with hermes_state_common.fts_rebuild_admission(None) as admitted:
assert admitted is True
class TestSchemaPathAdmission:
def test_startup_trigger_repair_defers_and_fails_closed(
self, tmp_path, fast_timeout
):
"""The _init_schema trigger-repair rebuild is covered by the SAME
authority — deferral must leave FTS detached with the durable stale
breadcrumb, never triggers installed over an unrebuilt index gap."""
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello schema path")
d.close()
# Drop one sync trigger out-of-band: next open takes the
# triggers_need_repair branch in _init_schema.
raw = sqlite3.connect(str(db_path))
raw.execute(f"DROP TRIGGER IF EXISTS {sorted(_FTS_TRIGGERS)[0]}")
raw.commit()
raw.close()
with _rebuild_lock_held_by_other_process(db_path):
d2 = SessionDB(db_path=db_path)
try:
assert d2._fts_enabled is False
finally:
d2.close()
# Durable state: stale breadcrumb set, no live sync triggers.
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
def test_stale_recovery_defers_then_succeeds_after_release(
self, tmp_path, fast_timeout
):
"""_recover_stale_fts defers under contention and completes once the
authority is free (next open)."""
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello recovery path")
d.close()
raw = sqlite3.connect(str(db_path))
raw.execute(
"INSERT INTO state_meta (key, value) VALUES (?, '1') "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(FTS_STALE_KEY,),
)
for trig in _FTS_TRIGGERS:
raw.execute(f"DROP TRIGGER IF EXISTS {trig}")
raw.commit()
raw.close()
with _rebuild_lock_held_by_other_process(db_path):
d2 = SessionDB(db_path=db_path)
try:
assert d2._fts_enabled is False
finally:
d2.close()
# Deferred: breadcrumb still present, recovery not performed.
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
d3 = SessionDB(db_path=db_path)
try:
assert d3._fts_enabled is True
finally:
d3.close()
# Recovered: breadcrumb cleared, triggers restored.
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS)
# ---------------------------------------------------------------------------
# Orphaned-fd staleness break (issue #100108).
#
# flock belongs to the open file DESCRIPTION, which fork() duplicates into
# children. A holder that forks (multiprocessing worker, daemonized helper)
# and then crashes leaves the flock held by the child forever — the kernel's
# holder-death release never fires, and every contender deferred forever
# ("FTS rebuild lock ... held by another process for more than 120s").
# The fix records the acquirer's pid + start time under the lock; a contender
# that times out breaks the lock ONLY when that recorded holder is provably
# dead, and fails closed on any indeterminate state.
# ---------------------------------------------------------------------------
_ORPHANING_HOLDER_SCRIPT = """
import os, sys, time
sys.path.insert(0, {repo!r})
import hermes_state_common
admission = hermes_state_common.fts_rebuild_admission({db!r})
admitted = admission.__enter__()
assert admitted is True
pid = os.fork()
if pid == 0:
# Forked child: shares the lock fd's open file description. Sleep far
# beyond the test, never releasing.
time.sleep(600)
os._exit(0)
print("child", pid, flush=True)
# Crash WITHOUT releasing (no __exit__): simulates the production holder
# dying mid-rebuild after having forked.
os._exit(1)
"""
@contextlib.contextmanager
def _orphaned_fork_holder(db_path: Path):
"""Real #100108 shape: acquirer records itself, forks, dies."""
import os
import signal
script = _ORPHANING_HOLDER_SCRIPT.format(
repo=str(Path(hermes_state_common.__file__).parent), db=str(db_path)
)
proc = subprocess.Popen(
[sys.executable, "-c", script], stdout=subprocess.PIPE, text=True
)
line = proc.stdout.readline().strip()
assert line.startswith("child ")
grandchild = int(line.split()[1])
proc.wait(timeout=10) # the acquirer is now dead; grandchild holds the fd
try:
yield grandchild
finally:
with contextlib.suppress(OSError):
os.kill(grandchild, signal.SIGKILL)
class TestOrphanedHolderStalenessBreak:
@pytest.mark.live_system_guard_bypass
def test_rebuild_breaks_lock_of_dead_forker(self, db, fast_timeout):
"""The #100108 repro: recorded holder dead, forked child holds the
flock. The contender must break the orphaned lock and rebuild."""
with _orphaned_fork_holder(db.db_path):
assert db.rebuild_fts() >= 1
def test_admission_still_fails_closed_for_live_unrecorded_holder(
self, db, fast_timeout
):
"""A live holder that wrote no record (pre-fix build, non-Hermes
tool) is indeterminate — must defer, never break."""
with _rebuild_lock_held_by_other_process(db.db_path):
assert db.rebuild_fts() == 0
def test_admission_fails_closed_for_live_recorded_holder(
self, db, fast_timeout, monkeypatch
):
"""A record naming a live pid must defer even after timeout."""
import json
import os
lock = _lock_file(db.db_path)
with _rebuild_lock_held_by_other_process(db.db_path) as proc:
record = {
"pid": proc.pid,
"start_ticks": hermes_state_common._proc_start_ticks(proc.pid),
"acquired_at": 0,
}
lock.write_bytes(json.dumps(record).encode())
assert db.rebuild_fts() == 0
def test_holder_record_cleared_on_normal_release(self, tmp_path):
lock = tmp_path / "x.db.fts_rebuild.lock"
with hermes_state_common.fts_rebuild_admission(tmp_path / "x.db") as ok:
assert ok is True
assert b"pid" in lock.read_bytes()
assert lock.read_bytes() == b""
@pytest.mark.live_system_guard_bypass
def test_repair_lock_breaks_orphaned_holder(self, tmp_path, monkeypatch):
"""_cross_process_repair_lock shares the same staleness break."""
import hermes_state
monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 0.5)
db_path = tmp_path / "state.db"
db_path.touch()
script = """
import os, sys, time
sys.path.insert(0, {repo!r})
from pathlib import Path
import hermes_state
lock_cm = hermes_state._cross_process_repair_lock(Path({db!r}))
assert lock_cm.__enter__() is True
pid = os.fork()
if pid == 0:
time.sleep(600)
os._exit(0)
print("child", pid, flush=True)
os._exit(1)
""".format(repo=str(Path(hermes_state_common.__file__).parent), db=str(db_path))
import os
import signal
proc = subprocess.Popen(
[sys.executable, "-c", script], stdout=subprocess.PIPE, text=True
)
grandchild = int(proc.stdout.readline().strip().split()[1])
proc.wait(timeout=10)
try:
import hermes_state as hs
with hs._cross_process_repair_lock(db_path) as holding:
assert holding is True
finally:
with contextlib.suppress(OSError):
os.kill(grandchild, signal.SIGKILL)
class TestNonContentionErrnoFailsFast:
def test_non_contention_oserror_does_not_wait_out_timeout(
self, tmp_path, monkeypatch
):
import fcntl
monkeypatch.setattr(
hermes_state_common, "_FTS_REBUILD_LOCK_TIMEOUT_SECONDS", 30.0
)
def _flock(*_args, **_kwargs):
raise OSError(getattr(errno, "ESTALE", errno.EIO), "stale handle")
monkeypatch.setattr(fcntl, "flock", _flock)
db_path = tmp_path / "state.db"
t0 = time.monotonic()
with hermes_state_common.fts_rebuild_admission(db_path) as admitted:
assert admitted is False
assert time.monotonic() - t0 < 2.0
def test_retry_deferred_fts_recovery_rebuilds_same_instance(
self, tmp_path, monkeypatch
):
"""Gateway-shaped: same SessionDB stays open and retries after deferral."""
import hermes_state_schema
monkeypatch.setattr(hermes_state_schema, "_FTS_STALE_RETRY_SECONDS", 0.0)
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello recovery path")
d.close()
raw = sqlite3.connect(str(db_path))
raw.execute(
"INSERT OR REPLACE INTO state_meta(key, value) VALUES (?, '1')",
(FTS_STALE_KEY,),
)
for trig in _FTS_TRIGGERS:
raw.execute(f"DROP TRIGGER IF EXISTS {trig}")
raw.commit()
raw.close()
holders = [(4242, str(db_path))]
monkeypatch.setattr(
SessionDB, "_foreign_state_db_holders", lambda self: list(holders)
)
d2 = SessionDB(db_path=db_path)
try:
assert d2._fts_stale is True
d2._fts_stale_retry_after = 0.0
assert d2.retry_deferred_fts_recovery() is False
holders.clear()
d2._fts_stale_retry_after = 0.0
assert d2.retry_deferred_fts_recovery() is True
assert d2._fts_stale is False
finally:
d2.close()
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS)
def test_non_contention_errno_skips_holder_warning(
self, tmp_path, monkeypatch, caplog
):
"""The fast-fail must not ALSO log the misleading 'held by another
process for more than Ns' line — there is no holder."""
import fcntl
import logging
monkeypatch.setattr(
hermes_state_common, "_FTS_REBUILD_LOCK_TIMEOUT_SECONDS", 30.0
)
def _flock(*_args, **_kwargs):
raise OSError(errno.ENOTSUP, "no locks on this fs")
monkeypatch.setattr(fcntl, "flock", _flock)
with caplog.at_level(logging.INFO, logger="hermes_state"):
with hermes_state_common.fts_rebuild_admission(
tmp_path / "state.db"
) as admitted:
assert admitted is False
messages = [r.getMessage() for r in caplog.records]
assert any("non-contention error" in m for m in messages)
assert not any("held by another process" in m for m in messages)
def test_repair_lock_non_contention_errno_fails_fast(
self, tmp_path, monkeypatch
):
"""Sibling site: the state.db repair lock shares the errno filter."""
import fcntl
import hermes_state
monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 30.0)
def _flock(*_args, **_kwargs):
raise OSError(errno.EIO, "i/o error")
monkeypatch.setattr(fcntl, "flock", _flock)
t0 = time.monotonic()
with hermes_state._cross_process_repair_lock(tmp_path / "state.db") as ok:
assert ok is False
assert time.monotonic() - t0 < 2.0
@pytest.mark.parametrize(
"exc, expected",
[
(BlockingIOError(errno.EAGAIN, "x"), True),
(OSError(errno.EWOULDBLOCK, "x"), True),
(OSError(errno.EACCES, "x"), True),
(OSError(errno.ESTALE, "x"), False),
(OSError(errno.ENOTSUP, "x"), False),
(OSError(errno.ENOLCK, "x"), False),
(OSError(errno.EIO, "x"), False),
(ValueError("not an oserror"), False),
],
)
def test_is_advisory_lock_contention_table(self, exc, expected):
assert hermes_state_common.is_advisory_lock_contention(exc) is expected
class TestDeferredFtsRetryInProcess:
"""Gateway shape (#100108): one SessionDB stays open for days. A deferral
at open must be recoverable from an in-process periodic tick, with the
REAL rebuild lock held by a REAL child process at open time."""
@staticmethod
def _mark_stale(db_path: Path) -> None:
raw = sqlite3.connect(str(db_path))
raw.execute(
"INSERT OR REPLACE INTO state_meta(key, value) VALUES (?, '1')",
(FTS_STALE_KEY,),
)
for trig in _FTS_TRIGGERS:
raw.execute(f"DROP TRIGGER IF EXISTS {trig}")
raw.commit()
raw.close()
def test_retry_is_non_blocking_while_live_holder_and_backs_off(
self, tmp_path, fast_timeout, monkeypatch
):
import hermes_state_schema
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello gateway retry")
d.close()
self._mark_stale(db_path)
with _rebuild_lock_held_by_other_process(db_path):
gw = SessionDB(db_path=db_path) # long-lived "gateway" open
try:
assert gw._fts_stale is True
# Live holder: the retry must return quickly (timeout=0),
# not wait out any admission budget.
monkeypatch.setattr(
hermes_state_common, "_FTS_REBUILD_LOCK_TIMEOUT_SECONDS", 30.0
)
t0 = time.monotonic()
assert gw.retry_deferred_fts_recovery() is False
assert time.monotonic() - t0 < 2.0
assert gw._fts_stale is True
# Rate limit engaged: an immediate second call is a no-op.
assert gw.retry_deferred_fts_recovery() is False
# Backoff doubled (60s -> 120s) but capped at the max.
assert gw._fts_stale_retry_interval == min(
2 * hermes_state_schema._FTS_STALE_RETRY_SECONDS,
hermes_state_schema._FTS_STALE_RETRY_MAX_SECONDS,
)
assert gw._fts_stale_retry_after > time.monotonic()
except BaseException:
gw.close()
raise
# Holder gone. Same instance recovers on the next eligible tick.
try:
gw._fts_stale_retry_after = 0.0
assert gw.retry_deferred_fts_recovery() is True
assert gw._fts_stale is False
assert gw._fts_enabled is True
# Search actually works again on this very instance.
gw.append_message("s1", "user", "needle-after-holder-gone")
assert gw.retry_deferred_fts_recovery() is False # nothing stale
finally:
gw.close()
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS)
def test_gateway_housekeeping_tick_drives_the_retry(
self, tmp_path, fast_timeout, monkeypatch
):
"""The retry hangs off the EXISTING housekeeping loop (no new thread)
and reaches shared-registry instances."""
import threading
import hermes_state_registry
import hermes_state_schema
import gateway.run as grun
monkeypatch.setattr(hermes_state_schema, "_FTS_STALE_RETRY_SECONDS", 0.0)
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello housekeeping")
d.close()
self._mark_stale(db_path)
with _rebuild_lock_held_by_other_process(db_path):
gw = hermes_state_registry.acquire(db_path)
try:
assert gw._fts_stale is True
assert gw in hermes_state_registry.live_shared_session_dbs()
stop = threading.Event()
th = threading.Thread(
target=grun._start_gateway_housekeeping,
args=(stop,),
kwargs={"interval": 0.05},
daemon=True,
)
th.start()
deadline = time.monotonic() + 10.0
while gw._fts_stale and time.monotonic() < deadline:
time.sleep(0.05)
stop.set()
th.join(timeout=5)
assert gw._fts_stale is False
assert gw._fts_enabled is True
finally:
hermes_state_registry.release_or_close(gw)
assert _meta_value(db_path, FTS_STALE_KEY) is None
def test_retry_noop_when_not_stale_or_read_only(self, tmp_path):
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
try:
assert d._fts_stale is False
assert d.retry_deferred_fts_recovery() is False
finally:
d.close()
ro = SessionDB(db_path=db_path, read_only=True)
try:
ro._fts_stale = True
assert ro.retry_deferred_fts_recovery() is False
finally:
ro.close()
def test_retry_skips_quarantined_handle(self, tmp_path, fast_timeout):
"""A structurally corrupt handle must never run a full FTS rebuild —
the housekeeping tick calls this unconditionally for the life of a
long-running gateway process, so a stale-FTS flag left set on a
now-corrupt handle must not retry the rebuild forever against the
damaged image (real DDL/DML the quarantine exists to prevent)."""
db_path = tmp_path / "state.db"
d = SessionDB(db_path=db_path)
if not d._fts_enabled:
d.close()
pytest.skip("FTS5 unavailable in this build")
d.create_session("s1", source="test")
d.append_message("s1", "user", "hello quarantine")
d.close()
self._mark_stale(db_path)
# Force the open-time recovery to defer (foreign rebuild-lock
# holder) so _fts_stale is still True once the handle is open —
# mirrors test_retry_is_non_blocking_while_live_holder_and_backs_off.
with _rebuild_lock_held_by_other_process(db_path):
gw = SessionDB(db_path=db_path)
try:
assert gw._fts_stale is True
gw._db_corrupt = True
gw._db_corrupt_reason = "database disk image is malformed"
# A retry that is DUE (backoff already elapsed) on a handle that
# had been backing off before it tripped quarantine. Seeding the
# deadline in the past matters: a future deadline would make the
# unguarded code short-circuit on the backoff check and this test
# would pass without the quarantine guard ever being exercised.
gw._fts_stale_retry_after = time.monotonic() - 1.0
gw._fts_stale_retry_interval = 900.0
assert gw.retry_deferred_fts_recovery() is False
# Untouched: still marked stale, triggers still absent — no
# rebuild ran against the "damaged" handle.
assert gw._fts_stale is True
# The backoff bookkeeping is reset too, mirroring the success
# path's own reset — a doubled interval left behind a flag
# nothing currently clears would otherwise make the next real
# retry (if this handle is ever un-quarantined) start from a
# stale multi-minute backoff instead of the default.
assert gw._fts_stale_retry_after == 0.0
assert gw._fts_stale_retry_interval == 0.0
finally:
gw.close()
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
+980
View File
@@ -0,0 +1,980 @@
"""Bounded FTS-corruption recovery on live SessionDB paths.
A corrupted FTS5 shadow table (``messages_fts_data``) makes every message
write raise ``sqlite3.DatabaseError: database disk image is malformed``
through the FTS sync triggers, while the canonical ``messages`` rows stay
intact. Before this fix the gateway swallowed the failure at debug level and
the in-memory session advanced while disk silently fell behind — surfacing
later as "Persisted transcript lagged live cached history" amnesia.
The fix records a durable stale marker, detaches the FTS sync triggers, and
retries the canonical write immediately. Live search degrades to canonical
``LIKE`` queries. The existing guarded stale-open or explicit repair path may
rebuild later, outside the failed live write/search operation.
"""
import json
import os
import sqlite3
import pytest
import hermes_state
import hermes_state_holders
import hermes_state_schema
from hermes_state import (
FTS_REBUILD_DEFERRAL_KEY,
FTS_STALE_KEY,
LEGACY_FTS_SQL,
LEGACY_FTS_TRIGRAM_SQL,
SCHEMA_SQL,
SessionDB,
_FTS_TRIGGERS,
_concrete_state_db_holder_pids,
_is_inactive_orphan_desktop_holder,
)
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
yield d
try:
d.close()
except Exception:
pass
def _corrupt_fts(db_path):
raw = sqlite3.connect(str(db_path))
raw.execute(
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
)
raw.commit()
raw.close()
def _corrupt_trigram_fts(db_path):
raw = sqlite3.connect(str(db_path))
raw.execute(
"UPDATE messages_fts_trigram_data "
"SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
)
raw.commit()
raw.close()
def _message_contents(db_path):
raw = sqlite3.connect(str(db_path))
rows = raw.execute("SELECT content FROM messages ORDER BY id").fetchall()
raw.close()
return [r[0] for r in rows]
def _meta_value(db_path, key):
raw = sqlite3.connect(str(db_path))
row = raw.execute(
"SELECT value FROM state_meta WHERE key = ?", (key,)
).fetchone()
raw.close()
return None if row is None else row[0]
def _base_fts_triggers(db_path):
raw = sqlite3.connect(str(db_path))
rows = raw.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' "
f"AND name IN ({','.join('?' for _ in _FTS_TRIGGERS)})",
_FTS_TRIGGERS,
).fetchall()
raw.close()
return {row[0] for row in rows}
class TestRuntimeFtsRebuild:
def test_reap_candidates_exclude_uninspectable_holder_suspicions(
self, tmp_path
):
db_path = tmp_path / "state.db"
assert _concrete_state_db_holder_pids(
db_path,
[
(222, "uninspectable holder: python -m hermes_cli.main serve --port 0"),
(-1, "open-file scan failed"),
],
) == []
def test_reap_candidates_deduplicate_multiple_proven_watched_fds(self, tmp_path):
db_path = tmp_path / "state.db"
assert _concrete_state_db_holder_pids(
db_path,
[
(222, str(db_path)),
(222, f"{db_path}-wal"),
(222, f"{db_path}-shm (deleted)"),
],
) == [222]
def test_inactive_orphan_reap_predicate_preserves_live_or_ambiguous_holders(self):
common = {
"ppid": 1,
"age_seconds": 120.0,
"min_age_seconds": 60.0,
"ephemeral_backend": True,
"connection_statuses": [],
}
assert _is_inactive_orphan_desktop_holder(**common)
assert not _is_inactive_orphan_desktop_holder(**{**common, "ppid": 42})
assert not _is_inactive_orphan_desktop_holder(
**{**common, "age_seconds": 10.0}
)
assert not _is_inactive_orphan_desktop_holder(
**{**common, "ephemeral_backend": False}
)
assert not _is_inactive_orphan_desktop_holder(
**{
**common,
"connection_statuses": ["ESTABLISHED"],
}
)
@pytest.mark.parametrize(
"argv",
(
("journalctl", "-u", "hermes-agent.service"),
("grep", "hermes-agent", "/var/log/syslog"),
(
"/usr/sbin/tailscaled",
"be-child",
"ssh",
"--cmd=python -m hermes_cli.main gateway",
),
("tmux", "new-session", "/opt/hermes-agent/.venv/bin/hermes gateway"),
("python3", "/opt/hermes-agent/tools/check_state.py"),
("hermes-monitor", "gateway"),
("hermesctl", "serve"),
("python3", "worker.py", "hermes_cli.main"),
("python3", "-m", "other.module", "hermes_cli.main"),
("python3", "-c", "hermes_cli.main"),
("python3", "-Icprint('hermes_cli.main')", "hermes_cli/main.py"),
),
)
def test_uninspectable_non_hermes_process_is_not_a_holder(self, argv):
assert not hermes_state_holders._looks_like_hermes(argv)
@pytest.mark.parametrize(
"argv",
(
("/usr/local/bin/hermes", "gateway"),
("/usr/local/bin/hermes-agent", "serve"),
("/usr/local/bin/hermes-acp", "--stdio"),
("/usr/bin/python3", "-m", "hermes_cli.main", "gateway"),
("/usr/bin/python3", "-m", "acp_adapter"),
("/usr/bin/python3", "-Im", "hermes_cli.main", "gateway"),
("/usr/bin/python3", "-mhermes_cli.main", "gateway"),
("/usr/bin/python3", "-W", "ignore", "-m", "hermes_cli.main"),
("/usr/bin/python3", "-Xdev", "-m", "hermes_cli.main"),
(
"/opt/hermes-agent/.venv/bin/python",
"/opt/hermes-agent/hermes_cli/main.py",
"gateway",
),
("python.exe", "--", "hermes_cli/main.py", "gateway"),
("python3", "/opt/hermes-agent/run_agent.py", "--query", "hello"),
),
)
def test_uninspectable_hermes_process_remains_a_holder(self, argv):
assert hermes_state_holders._looks_like_hermes(argv)
@pytest.mark.linux_only
def test_foreign_holder_detection_proc_readlink_deleted_wal(
self, db, tmp_path, monkeypatch
):
"""Linux /proc/<pid>/fd readlinks preserve '(deleted)' suffix.
psutil.open_files() drops these entries (isfile_strict stats the
literal path and fails). The /proc path catches the split-brain
holder that psutil silently misses.
"""
db_path = tmp_path / "state.db"
db_path_wal = str(db_path) + "-wal"
# Build a fake /proc with two PIDs: self (111) and foreign (222).
proc_root = tmp_path / "proc"
for pid in (111, 222, 333):
fd_dir = proc_root / str(pid) / "fd"
fd_dir.mkdir(parents=True)
# PID 222 holds the deleted WAL sidecar
os.symlink(db_path_wal + " (deleted)", str(proc_root / "222" / "fd" / "3"))
# PID 111 (self) holds the db — should be excluded
os.symlink(str(db_path), str(proc_root / "111" / "fd" / "3"))
# PID 333 holds an unrelated file
other = tmp_path / "other.db"
other.touch()
os.symlink(str(other), str(proc_root / "333" / "fd" / "3"))
monkeypatch.setattr(hermes_state_holders.os, "getpid", lambda: 111)
real_listdir = os.listdir
def _listdir(path):
if isinstance(path, str):
path = path.replace("/proc", str(proc_root))
return real_listdir(path)
monkeypatch.setattr(hermes_state_holders.os, "listdir", _listdir)
real_readlink = os.readlink
def _readlink(path):
path = path.replace("/proc", str(proc_root))
return real_readlink(path)
monkeypatch.setattr(hermes_state_holders.os, "readlink", _readlink)
real_stat = os.stat
def _stat(path, *args, **kwargs):
path_s = str(path).replace("/proc", str(proc_root))
if path_s.endswith("/222/fd/3"):
# A real /proc fd remains statable after unlink and retains
# the deleted sidecar's filesystem identity: same device as
# state.db, but an inode no live watched path can reach.
fields = list(real_stat(db_path))
fields[1] += 1000
return os.stat_result(fields)
return real_stat(path_s, *args, **kwargs)
monkeypatch.setattr(hermes_state_holders.os, "stat", _stat)
holders = hermes_state_holders.foreign_state_db_holders(db_path)
assert holders == [(222, db_path_wal + " (deleted)")]
@pytest.mark.linux_only
@pytest.mark.parametrize("different_device", (False, True))
def test_foreign_holder_ignores_same_path_with_different_file_identity(
self, db, tmp_path, monkeypatch, different_device
):
"""A namespace peer's different state.db is not a holder of the host's.
A peer process can appear in /proc with a string-identical path for a
different inode, either on the same filesystem or a different one.
Matching on path alone -- or on device alone -- would defer automatic
FTS maintenance forever while corruption compounds.
Identity must come from (st_dev, st_ino), not the path text.
"""
db_path = tmp_path / "state.db"
proc_root = tmp_path / "proc"
for pid in (111, 222):
(proc_root / str(pid) / "fd").mkdir(parents=True)
# PID 222 = container process holding ITS OWN state.db, which happens
# to have the identical absolute path inside its mount namespace.
guest_db = tmp_path / "guest_state.db"
guest_db.touch()
os.symlink(str(guest_db), str(proc_root / "222" / "fd" / "3"))
monkeypatch.setattr(hermes_state_holders.os, "getpid", lambda: 111)
real_listdir = os.listdir
def _listdir(path):
if isinstance(path, str):
path = path.replace("/proc", str(proc_root))
return real_listdir(path)
monkeypatch.setattr(hermes_state_holders.os, "listdir", _listdir)
# The guest fd reports the host's path (identical string), which is
# exactly what the kernel shows across mount namespaces.
def _readlink(path):
path = path.replace("/proc", str(proc_root))
if path.endswith(f"{proc_root}/222/fd/3") or "222" in path:
return str(db_path)
return os.readlink(path)
monkeypatch.setattr(hermes_state_holders.os, "readlink", _readlink)
# ...but stat()ing the descriptor resolves to the peer's own inode.
real_stat = os.stat
def _stat(path, *a, **kw):
path_s = str(path).replace("/proc", str(proc_root))
st = real_stat(path_s, *a, **kw)
if different_device and path_s.endswith("/222/fd/3"):
fields = list(st)
# os.stat_result positional layout: st_dev is index 2.
fields[2] = st.st_dev + 1000
return os.stat_result(fields)
return st
monkeypatch.setattr(hermes_state_holders.os, "stat", _stat)
assert hermes_state_holders.foreign_state_db_holders(db_path) == []
@pytest.mark.linux_only
def test_foreign_holder_uninspectable_process_cmdline_fallback(
self, db, tmp_path, monkeypatch
):
"""A process whose fd table is unreadable (different user) is still
flagged when /proc/<pid>/cmdline identifies it as a Hermes process."""
db_path = tmp_path / "state.db"
proc_root = tmp_path / "proc"
for pid in (111, 222):
(proc_root / str(pid) / "fd").mkdir(parents=True)
# PID 222's fd dir is unreadable (PermissionError)
os.chmod(proc_root / "222" / "fd", 0o000)
# PID 222's cmdline is world-readable and looks like Hermes
cmdline_path = proc_root / "222" / "cmdline"
cmdline_path.write_bytes(
b"python3\x00-m\x00hermes_cli.main\x00chat\x00"
)
monkeypatch.setattr(hermes_state_holders.os, "getpid", lambda: 111)
real_listdir = os.listdir
def _listdir(path):
if isinstance(path, str):
if path == "/proc/222/fd":
raise PermissionError(path)
path = path.replace("/proc", str(proc_root))
return real_listdir(path)
monkeypatch.setattr(hermes_state_holders.os, "listdir", _listdir)
# _read_proc_argv opens /proc/<pid>/cmdline directly; redirect
# it to our fake proc tree.
def _fake_argv(pid):
fake_path = str(proc_root / str(pid) / "cmdline")
try:
with open(fake_path, "rb") as f:
raw = f.read()
if not raw:
return None
return raw.decode("utf-8", "replace").rstrip("\x00").split("\x00")
except OSError:
return None
monkeypatch.setattr(hermes_state_holders, "_read_proc_argv", _fake_argv)
holders = hermes_state_holders.foreign_state_db_holders(db_path)
# Should include PID 222 with the cmdline info
assert len(holders) == 1
assert holders[0][0] == 222
assert "hermes_cli.main" in holders[0][1]
# Cleanup
os.chmod(proc_root / "222" / "fd", 0o755)
def test_corruption_error_classification_requires_fts_evidence(self):
"""Generic structural corruption must not enter live FTS repair.
Older SQLite builds may use the generic malformed-image text for an FTS
virtual-table failure, but still expose SQLITE_CORRUPT_VTAB. Preserve
that route while failing closed for unscoped SQLITE_CORRUPT errors.
"""
generic = sqlite3.DatabaseError("database disk image is malformed")
assert not SessionDB._is_fts_write_corruption_error(generic)
structural = sqlite3.DatabaseError("database disk image is malformed")
structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT
structural.sqlite_errorname = "SQLITE_CORRUPT"
assert not SessionDB._is_fts_write_corruption_error(structural)
fts_virtual_table = sqlite3.DatabaseError("database disk image is malformed")
fts_virtual_table.sqlite_errorcode = sqlite3.SQLITE_CORRUPT_VTAB
fts_virtual_table.sqlite_errorname = "SQLITE_CORRUPT_VTAB"
assert SessionDB._is_fts_write_corruption_error(fts_virtual_table)
contradictory = sqlite3.IntegrityError(
'fts5: corrupt structure record for table "messages_fts"'
)
contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER
contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER"
assert not SessionDB._is_fts_write_corruption_error(contradictory)
assert SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError(
'fts5: corrupt structure record for table "messages_fts"'
)
)
assert not SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError("no such table: nothing_fts_related")
)
def test_structural_corruption_propagates_without_live_fts_mutation(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
rebuild_called = False
def _unexpected_rebuild():
nonlocal rebuild_called
rebuild_called = True
raise AssertionError("structural corruption must not rebuild FTS")
monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild)
structural = sqlite3.DatabaseError("database disk image is malformed")
structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT
structural.sqlite_errorname = "SQLITE_CORRUPT"
with pytest.raises(sqlite3.DatabaseError) as caught:
db._execute_write(lambda _conn: (_ for _ in ()).throw(structural))
# Structural corruption quarantines the handle: the typed error wraps
# the original (cause preserved, SQLite result code copied) and the
# sticky flag is set, so later writes fail fast.
from hermes_state import StateDbCorruptError
assert isinstance(caught.value, StateDbCorruptError)
assert caught.value.__cause__ is structural
assert caught.value.sqlite_errorcode == sqlite3.SQLITE_CORRUPT
assert db._db_corrupt is True
assert rebuild_called is False
assert db._fts_stale is False
assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None
assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS)
def test_fts_looking_constraint_error_does_not_mutate_fts(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
rebuild_called = False
def _unexpected_rebuild():
nonlocal rebuild_called
rebuild_called = True
raise AssertionError("contradictory error code must fail closed")
monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild)
contradictory = sqlite3.IntegrityError(
'fts5: corrupt structure record for table "messages_fts"'
)
contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER
contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER"
with pytest.raises(sqlite3.IntegrityError) as caught:
db._execute_write(lambda _conn: (_ for _ in ()).throw(contradictory))
assert caught.value is contradictory
assert rebuild_called is False
assert db._fts_stale is False
assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None
assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS)
def test_append_defers_rebuild_after_fts_corruption(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "hello world")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: pytest.fail("live write must not rebuild the full FTS index"),
)
# The canonical write survives without waiting for a full index scan.
msg_id = db.append_message("s1", "user", "healed append")
assert msg_id is not None
assert _message_contents(db_path) == [
"hello world",
"healed append",
]
assert db._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
def test_search_works_from_canonical_rows_after_fail_open(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "before corruption")
_corrupt_fts(db_path)
db.append_message("s1", "user", "searchable needle text")
results = db.search_messages("needle")
assert results
assert any("needle" in (row.get("snippet") or "") for row in results)
assert db._fts_stale is True
def test_search_messages_defers_rebuild_after_fts_corruption(
self, db, tmp_path, monkeypatch
):
"""A read-only session that only SEARCHES (no write after corruption)
must stay available without starting an unbounded index scan.
"""
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "a searchable needle here")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: pytest.fail("live search must not rebuild the full FTS index"),
)
results = db.search_messages("needle")
assert db._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
assert results
assert any("needle" in (r.get("snippet") or "") for r in results)
def test_trigram_search_defers_rebuild_after_fts_corruption(
self, db, tmp_path, monkeypatch
):
"""The CJK/trigram MATCH branch has the same read-corruption exposure
as the main FTS5 branch and must fall back to canonical rows.
"""
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
if not db._trigram_available:
pytest.skip("trigram tokenizer unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "关于大别山项目的进展报告")
_corrupt_trigram_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: pytest.fail("live search must not rebuild the full FTS index"),
)
# >=3 CJK chars per token → routed to the trigram branch.
results = db.search_messages("大别山项目")
assert db._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
assert results
assert any("大别山项目" in (r.get("snippet") or "") for r in results)
def test_corruption_fails_open_and_rebuilds_on_reopen(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
db.append_message("s1", "user", "corruption survives")
assert _message_contents(db_path) == [
"seed",
"corruption survives",
]
assert db._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
# Search remains available from canonical rows while FTS is stale.
results = db.search_messages("corruption survives")
assert results
assert any("corruption survives" in row["snippet"] for row in results)
# A later open atomically rebuilds all canonical rows before triggers
# return, then clears the durable breadcrumb.
db.close()
reopened = SessionDB(db_path=db_path)
try:
assert reopened._fts_stale is False
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS)
results = reopened.search_messages("corruption survives")
assert results
finally:
reopened.close()
def test_non_fts_write_error_after_fail_open_raises_not_hangs(
self, db, tmp_path
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
db.append_message("s1", "user", "canonical survives")
def _persistent_non_fts_error(conn):
raise sqlite3.DatabaseError("routine integrity check failed")
with pytest.raises(sqlite3.DatabaseError, match="routine integrity"):
db._execute_write(_persistent_non_fts_error)
def test_live_write_does_not_scan_foreign_holders(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"_foreign_state_db_holders",
lambda: pytest.fail("live fail-open must not enter rebuild admission"),
raising=False,
)
db.append_message("s1", "user", "canonical survives foreign holder")
assert _message_contents(db_path)[-1] == "canonical survives foreign holder"
assert db._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
def test_stale_search_preserves_not_semantics(self, db, tmp_path, monkeypatch):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "python language guide")
db.append_message("s1", "user", "python java interoperability")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: (_ for _ in ()).throw(
sqlite3.DatabaseError("rebuild could not read corrupt FTS")
),
)
db.append_message("s1", "user", "canonical write survives")
assert db._fts_stale is True
results = db.search_messages("python NOT java")
snippets = [row["snippet"] for row in results]
assert any("python language guide" in snippet for snippet in snippets)
assert all("java" not in snippet for snippet in snippets)
def test_existing_peer_observes_fail_open_marker(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
peer = SessionDB(db_path=db_path)
try:
_corrupt_fts(db_path)
def _failed_rebuild():
raise sqlite3.DatabaseError("rebuild failed")
monkeypatch.setattr(db, "rebuild_fts", _failed_rebuild)
db.append_message("s1", "user", "visible through canonical search")
assert peer._fts_stale is False
results = peer.search_messages("canonical search")
assert peer._fts_stale is True
assert results
finally:
peer.close()
def test_failed_startup_rebuild_keeps_fts_detached(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")),
)
db.append_message("s1", "user", "before restart")
db.close()
monkeypatch.setattr(
SessionDB,
"_recover_stale_fts",
lambda self, cursor, legacy: False,
)
reopened = SessionDB(db_path=db_path)
try:
assert reopened._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
reopened.append_message("s1", "user", "after failed recovery")
assert _message_contents(db_path)[-1] == "after failed recovery"
assert reopened.search_messages("failed recovery")
finally:
reopened.close()
def test_foreign_holder_defers_startup_stale_rebuild(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")),
)
db.append_message("s1", "user", "before restart")
db.close()
monkeypatch.setattr(
SessionDB,
"_foreign_state_db_holders",
lambda self: [(4242, str(db_path) + "-wal")],
raising=False,
)
reopened = SessionDB(db_path=db_path)
try:
assert reopened._fts_stale is True
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
assert _base_fts_triggers(db_path) == set()
reopened.append_message("s1", "user", "after deferred recovery")
assert _message_contents(db_path)[-1] == "after deferred recovery"
finally:
reopened.close()
def test_repeated_deferrals_reap_inactive_orphan_then_rebuild(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db_path = tmp_path / "state.db"
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(db_path)
monkeypatch.setattr(
db,
"rebuild_fts",
lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")),
)
db.append_message("s1", "user", "before restart")
db.close()
raw = sqlite3.connect(str(db_path))
raw.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(
FTS_REBUILD_DEFERRAL_KEY,
json.dumps({"first_seen": 1.0, "last_seen": 30.0, "attempts": 2}),
),
)
raw.commit()
raw.close()
holder_scans = iter(([(4242, str(db_path) + "-wal")], []))
reaped = []
monkeypatch.setattr(
SessionDB,
"_foreign_state_db_holders",
lambda self: next(holder_scans),
)
monkeypatch.setattr(
SessionDB,
"_reap_inactive_orphan_desktop_holders",
lambda self, holders, *, min_age_seconds: reaped.extend(holders) or [4242],
)
monkeypatch.setattr(hermes_state_schema.time, "time", lambda: 120.0)
reopened = SessionDB(db_path=db_path)
try:
assert reaped == [(4242, str(db_path) + "-wal")]
assert reopened._fts_stale is False
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert _meta_value(db_path, FTS_REBUILD_DEFERRAL_KEY) is None
assert reopened.search_messages("before restart")
finally:
reopened.close()
def test_legacy_inline_fts_fails_open_and_recovers(self, tmp_path, monkeypatch):
db_path = tmp_path / "legacy-state.db"
raw = sqlite3.connect(str(db_path))
raw.executescript(SCHEMA_SQL)
try:
raw.executescript(LEGACY_FTS_SQL + LEGACY_FTS_TRIGRAM_SQL)
except sqlite3.OperationalError as exc:
raw.close()
pytest.skip(f"required FTS tokenizer unavailable: {exc}")
raw.commit()
raw.close()
legacy = SessionDB(db_path=db_path)
try:
assert legacy._db_has_legacy_inline_fts(legacy._conn.cursor())
legacy.create_session("s1", source="test")
legacy.append_message("s1", "user", "legacy seed")
_corrupt_fts(db_path)
monkeypatch.setattr(
legacy,
"rebuild_fts",
lambda: (_ for _ in ()).throw(
sqlite3.DatabaseError("legacy rebuild failed")
),
)
legacy.append_message("s1", "user", "legacy canonical survives")
assert _message_contents(db_path)[-1] == "legacy canonical survives"
assert _meta_value(db_path, FTS_STALE_KEY) == "1"
finally:
legacy.close()
recovered = SessionDB(db_path=db_path)
try:
assert recovered._fts_stale is False
assert _meta_value(db_path, FTS_STALE_KEY) is None
assert recovered.search_messages("canonical survives")
finally:
recovered.close()
def _corrupt_canonical_btree(db_path):
"""Physically damage every ``messages`` table B-tree leaf page.
Real byte-flip corruption (no mocks): checkpoint the WAL so all pages are
in the main file, locate the leaves via ``dbstat``, and clobber each leaf's
page-header cell-count bytes. Any subsequent write or read touching the
``messages`` tree raises a genuine bare ``SQLITE_CORRUPT`` from SQLite.
"""
raw = sqlite3.connect(str(db_path))
try:
raw.execute("PRAGMA wal_checkpoint(TRUNCATE)")
page_size = raw.execute("PRAGMA page_size").fetchone()[0]
try:
leaves = [
row[0]
for row in raw.execute(
"SELECT pageno FROM dbstat "
"WHERE name='messages' AND pagetype='leaf'"
).fetchall()
]
except sqlite3.Error:
pytest.skip("dbstat virtual table unavailable in this build")
finally:
raw.close()
assert leaves, "expected at least one messages leaf page"
with open(db_path, "r+b") as f:
for leaf in leaves:
f.seek((leaf - 1) * page_size + 3)
f.write(b"\xff\xff\xff\xff")
class TestPhysicalCorruptionAcceptance:
"""Real-fixture acceptance tests for the fail-closed classifier (#97940).
The field incident behind issue #97940: canonical B-trees were physically
damaged, SQLite raised the generic ``database disk image is malformed``,
and the over-broad classifier routed it into the FTS-only self-heal —
logging "canonical message rows are preserved" while transcript writes
silently failed for ~10 hours. These tests damage a real database with
byte flips (canonical tree) and shadow-table stomps (FTS-only) and assert
the two corruption classes are handled differently end to end.
"""
def test_canonical_btree_corruption_fails_closed(
self, tmp_path, caplog
):
"""Bare SQLITE_CORRUPT from real canonical damage must propagate.
No FTS rebuild attempt, no trigger detach, no stale marker, and —
critically — no log line claiming canonical rows are preserved.
"""
db_path = tmp_path / "state.db"
seed = SessionDB(db_path=db_path)
try:
if not seed._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
seed.create_session("s1", source="test")
for i in range(300):
seed.append_message("s1", "user", f"canon row {i} " + "y" * 300)
finally:
seed.close()
_corrupt_canonical_btree(db_path)
db = SessionDB(db_path=db_path)
try:
caplog.clear()
with caplog.at_level("WARNING", logger="hermes_state"):
with pytest.raises(sqlite3.DatabaseError) as caught:
db.append_message("s1", "user", "post-corruption write")
# The genuine structural error propagated, not an FTS retry result.
assert getattr(caught.value, "sqlite_errorcode", None) in (
sqlite3.SQLITE_CORRUPT,
None, # very old sqlite3 modules without errorcode attrs
)
assert "malformed" in str(caught.value).lower()
# The classifier refused the FTS route entirely.
assert not SessionDB._is_fts_write_corruption_error(caught.value)
assert getattr(db, "_fts_runtime_rebuild_attempted", False) is False
assert db._fts_stale is False
# The misdiagnosis message from the field incident must be gone.
assert "canonical message rows are preserved" not in caplog.text
assert "attempting one-shot in-place FTS rebuild" not in caplog.text
# Structural damage quarantines the handle: typed error, sticky
# flag, later writes fail fast, and close() must not checkpoint
# the WAL over a damaged page image (the #90950 page-1 clobber).
from hermes_state import StateDbCorruptError
assert isinstance(caught.value, StateDbCorruptError)
assert db._db_corrupt is True
with pytest.raises(StateDbCorruptError):
db.append_message("s1", "user", "second write after corruption")
caplog.clear()
with caplog.at_level("WARNING", logger="hermes_state"):
db.close()
assert "Skipping the close-time WAL checkpoint" in caplog.text
finally:
db.close()
# Fail-closed also means non-destructive: triggers untouched, no
# stale-FTS marker persisted for a structural (non-FTS) failure.
assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS)
assert _meta_value(db_path, FTS_STALE_KEY) is None
def test_fts_only_corruption_still_self_heals(self, db, tmp_path):
"""Contrast case: a real FTS shadow-table stomp raises
SQLITE_CORRUPT_VTAB, is classified as FTS-scoped, and the write path
self-heals with canonical rows intact — proving the narrowed
classifier did not break the legitimate FTS repair route."""
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "before stomp")
for i in range(50):
db.append_message("s1", "user", f"seed row {i} " + "z" * 200)
_corrupt_fts(tmp_path / "state.db")
# Prove the fixture produces the FTS-scoped extended code for the
# classifier (provenance check via a raw connection so no self-heal
# runs). A MATCH read walks the stomped structure record on every
# SQLite version; the insert trigger only trips on some versions.
raw = sqlite3.connect(str(tmp_path / "state.db"))
try:
raw.execute(
"SELECT rowid FROM messages_fts WHERE messages_fts MATCH 'seed'"
).fetchall()
except sqlite3.DatabaseError as exc:
assert getattr(exc, "sqlite_errorcode", None) == getattr(
sqlite3, "SQLITE_CORRUPT_VTAB", 267
)
assert SessionDB._is_fts_write_corruption_error(exc)
else: # pragma: no cover - fixture must corrupt the index
pytest.fail("FTS stomp fixture did not corrupt the index")
finally:
raw.close()
# And the app-level write path still succeeds after real FTS-only
# damage (self-heals on builds whose sync triggers surface the
# corruption; passes through untouched on builds that defer it).
msg_id = db.append_message("s1", "user", "healed after stomp")
assert msg_id is not None
contents = _message_contents(tmp_path / "state.db")
assert contents[0] == "before stomp"
assert contents[-1] == "healed after stomp"
assert len(contents) == 52
@@ -0,0 +1,254 @@
"""Cron-source exclusion from the external-content trigram FTS index."""
from __future__ import annotations
import sqlite3
import pytest
from hermes_state import FTS_TRIGRAM_SQL, SCHEMA_VERSION, SessionDB
@pytest.fixture
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
if not session_db._trigram_available:
session_db.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
yield session_db
session_db.close()
def _trigram_rowids(db: SessionDB) -> set[int]:
return {
row[0]
for row in db._conn.execute(
"SELECT id FROM messages_fts_trigram_docsize ORDER BY id"
).fetchall()
}
def _install_pre_v27_trigram(db: SessionDB, *, with_tool_calls: bool = False) -> None:
"""Recreate the pre-cron-exclusion external-content trigram boundary.
``with_tool_calls=True`` reproduces the FTS_STORAGE_VERSION 1 vtable
(``tool_calls`` projected) that installs upgraded before #88217 carry;
the default is the v2 column set with only the view/trigger predicates
behind, which is what the in-place v29 migration handles.
"""
cols = "content, tool_name" + (", tool_calls" if with_tool_calls else "")
vals = "new.content, new.tool_name" + (", new.tool_calls" if with_tool_calls else "")
db._conn.executescript(
f"""
DROP TRIGGER messages_fts_trigram_insert;
DROP TRIGGER messages_fts_trigram_delete;
DROP TRIGGER messages_fts_trigram_update;
DROP TABLE messages_fts_trigram;
DROP VIEW messages_fts_trigram_src;
CREATE VIEW messages_fts_trigram_src AS
SELECT id, role, content, tool_name, tool_calls
FROM messages WHERE role <> 'tool';
CREATE VIRTUAL TABLE messages_fts_trigram USING fts5(
{cols},
content='messages_fts_trigram_src',
content_rowid='id',
tokenize='trigram'
);
CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
BEGIN
INSERT INTO messages_fts_trigram(rowid, {cols})
VALUES (new.id, {vals});
END;
"""
)
def test_fresh_trigram_indexes_conversations_but_not_cron(db: SessionDB):
db.create_session("cli", source="cli")
db.create_session("cron", source="cron")
cli_id = db.append_message("cli", role="user", content="交付状态正常")
cron_id = db.append_message("cron", role="user", content="定时任务状态正常")
assert _trigram_rowids(db) == {cli_id}
assert cron_id not in _trigram_rowids(db)
assert db._conn.execute(
"SELECT id FROM messages_fts_docsize WHERE id = ?", (cron_id,)
).fetchone() is not None
def test_cron_remains_searchable_via_standard_fts_and_explicit_cjk_fallback(
db: SessionDB,
):
db.create_session("cron", source="cron")
db.append_message(
"cron", role="assistant", content="quarterly archive 大别山项目 complete"
)
assert [row["session_id"] for row in db.search_messages("quarterly")] == [
"cron"
]
assert [
row["session_id"]
for row in db.search_messages("大别山项目", source_filter=["cron"])
] == ["cron"]
def test_deferred_rebuild_does_not_reintroduce_cron(db: SessionDB):
db.create_session("cli", source="cli")
db.create_session("cron", source="cron")
cli_id = db.append_message("cli", role="assistant", content="交互会话内容")
db.append_message("cron", role="assistant", content="定时会话内容")
with db._lock:
db._reset_fts_index_to_empty(db._conn)
db._seed_fts_rebuild_markers(db._conn, force=True)
db._conn.commit()
while db.fts_rebuild_step():
pass
assert _trigram_rowids(db) == {cli_id}
def test_existing_external_layout_rebuilds_trigram_on_upgrade(tmp_path):
db_path = tmp_path / "state.db"
old = SessionDB(db_path=db_path)
if not old._trigram_available:
old.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
# The virtual table keeps referring to the view by name, so this recreates
# the exact old external-content boundary without reading source text.
_install_pre_v27_trigram(old)
old.create_session("cli", source="cli")
old.create_session("cron", source="cron")
cli_id = old.append_message("cli", role="user", content="交互迁移内容")
cron_id = old.append_message("cron", role="user", content="定时迁移内容")
assert _trigram_rowids(old) == {cli_id, cron_id}
old._conn.execute("UPDATE schema_version SET version = ?", (SCHEMA_VERSION - 1,))
old._conn.commit()
old.close()
migrated = SessionDB(db_path=db_path)
try:
assert _trigram_rowids(migrated) == {cli_id}
view_sql = migrated._conn.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'view' AND name = 'messages_fts_trigram_src'"
).fetchone()[0]
assert "sessions" in view_sql
assert "cron" in view_sql
migrated._conn.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')"
)
finally:
migrated.close()
def test_install_already_at_v28_still_gets_the_cron_exclusion_migration(tmp_path):
"""The migration gate must fire for installs that were on main's v28.
The original PR gated on ``current_version < 27``; main had meanwhile
reached SCHEMA_VERSION 28 via column-reconciliation bumps, so a v28
database would have skipped the rebuild and kept cron rows in the trigram
index forever. Pin the gate against the version main actually shipped.
"""
db_path = tmp_path / "state.db"
old = SessionDB(db_path=db_path)
if not old._trigram_available:
old.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
_install_pre_v27_trigram(old)
old.create_session("cli", source="cli")
old.create_session("cron", source="cron")
cli_id = old.append_message("cli", role="user", content="交互迁移内容")
cron_id = old.append_message("cron", role="user", content="定时迁移内容")
assert _trigram_rowids(old) == {cli_id, cron_id}
old._conn.execute("UPDATE schema_version SET version = 28")
old._conn.commit()
old.close()
migrated = SessionDB(db_path=db_path)
try:
assert _trigram_rowids(migrated) == {cli_id}, (
"a v28 database kept cron rows in the trigram index: the migration gate did not fire"
)
finally:
migrated.close()
def test_v1_tool_calls_layout_is_left_for_optimize_storage(tmp_path):
"""A FTS_STORAGE_VERSION 1 trigram vtable (``tool_calls`` projected) must
survive the v29 startup migration untouched and be finished by the opt-in
``optimize_fts_storage`` path — not half-migrated into a view/vtable
column mismatch (which used to fail the rebuild with
``no such column: T.tool_calls``)."""
db_path = tmp_path / "state.db"
old = SessionDB(db_path=db_path)
if not old._trigram_available:
old.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
_install_pre_v27_trigram(old, with_tool_calls=True)
old.create_session("cli", source="cli")
old.create_session("cron", source="cron")
cli_id = old.append_message("cli", role="user", content="交互迁移内容")
cron_id = old.append_message("cron", role="user", content="定时迁移内容")
assert _trigram_rowids(old) == {cli_id, cron_id}
old._conn.execute("UPDATE schema_version SET version = 28")
old._conn.commit()
old.close()
migrated = SessionDB(db_path=db_path) # must not raise
try:
# Startup left the v1 layout alone (cron row still there) …
assert _trigram_rowids(migrated) == {cli_id, cron_id}
assert migrated.fts_optimize_available() is True
# … and the opt-in path completes the transition: v2 columns,
# cron-filtered view, cron row purged.
migrated.optimize_fts_storage()
cols = [r[1] for r in migrated._conn.execute("PRAGMA table_info(messages_fts_trigram)")]
assert "tool_calls" not in cols
assert _trigram_rowids(migrated) == {cli_id}
finally:
migrated.close()
def test_partial_upgrade_view_does_not_skip_historical_rebuild(tmp_path):
db_path = tmp_path / "state.db"
old = SessionDB(db_path=db_path)
if not old._trigram_available:
old.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
_install_pre_v27_trigram(old)
old.create_session("cron", source="cron")
cron_id = old.append_message("cron", role="assistant", content="迁移中断内容")
assert _trigram_rowids(old) == {cron_id}
# Simulate a crash after new DDL landed but before the rebuild/schema stamp.
for name in (
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
old._conn.execute(f"DROP TRIGGER IF EXISTS {name}")
old._conn.execute("DROP VIEW messages_fts_trigram_src")
old._conn.executescript(FTS_TRIGRAM_SQL)
old._conn.execute("UPDATE schema_version SET version = ?", (SCHEMA_VERSION - 1,))
old._conn.commit()
old.close()
migrated = SessionDB(db_path=db_path)
try:
assert _trigram_rowids(migrated) == set()
finally:
migrated.close()
def test_delete_of_unindexed_cron_row_keeps_trigram_consistent(db: SessionDB):
db.create_session("cron", source="cron")
cron_id = db.append_message("cron", role="user", content="不会进入索引")
assert cron_id not in _trigram_rowids(db)
db._conn.execute("DELETE FROM messages WHERE id = ?", (cron_id,))
db._conn.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')"
)
@@ -0,0 +1,161 @@
"""Delegate-child (subagent) transcripts stay out of the trigram FTS index (v30).
Mirrors ``test_fts_trigram_cron_exclusion.py``: children are canonical rows
in ``messages`` and stay searchable through the standard ``messages_fts``
word index; only the trigram (CJK substring) shadow index skips them.
"""
from __future__ import annotations
import pytest
from hermes_state import SCHEMA_VERSION, SessionDB
from hermes_state_common import FTS_TRIGRAM_EXCLUDED_SOURCES, fts_trigram_session_sql
@pytest.fixture
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
if not session_db._trigram_available:
session_db.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
yield session_db
session_db.close()
def _trigram_rowids(db: SessionDB) -> set[int]:
return {
row[0]
for row in db._conn.execute("SELECT id FROM messages_fts_trigram_docsize").fetchall()
}
def _fts_rowids(db: SessionDB) -> set[int]:
return {
row[0] for row in db._conn.execute("SELECT id FROM messages_fts_docsize").fetchall()
}
def _seed(db: SessionDB) -> dict[str, int]:
db.create_session("root", source="cli")
# delegate_tool children: source='subagent' via platform, plus the
# _delegate_from creation marker.
db.create_session(
"kid", source="subagent", parent_session_id="root",
model_config={"_delegate_from": "root"},
)
# A child spawned under a gateway turn inherits the gateway's source but
# still carries the marker.
db.create_session(
"gw-kid", source="telegram", parent_session_id="root",
model_config={"_delegate_from": "root"},
)
# Compression continuation: parent_session_id but NO marker -> indexed.
db.create_session("cont", source="cli", parent_session_id="root")
return {
"root": db.append_message("root", role="user", content="交付状态正常 root-word"),
"kid": db.append_message("kid", role="assistant", content="子任务状态正常 kid-word"),
"gw-kid": db.append_message("gw-kid", role="assistant", content="网关子任务 gwkid-word"),
"cont": db.append_message("cont", role="assistant", content="继续会话内容 cont-word"),
}
def test_subagent_rows_skip_trigram_but_stay_in_standard_fts(db: SessionDB):
ids = _seed(db)
assert _trigram_rowids(db) == {ids["root"], ids["cont"]}
assert _fts_rowids(db) >= set(ids.values())
def test_subagent_rows_remain_word_searchable(db: SessionDB):
_seed(db)
assert [r["session_id"] for r in db.search_messages("kid-word")] == ["kid"]
assert [r["session_id"] for r in db.search_messages("gwkid-word")] == ["gw-kid"]
# Explicit CJK search scoped to the excluded source falls back to LIKE.
assert [
r["session_id"]
for r in db.search_messages("子任务状态", source_filter=["subagent"])
] == ["kid"]
# Top-level CJK substring search unaffected.
assert [r["session_id"] for r in db.search_messages("交付状态")] == ["root"]
def test_update_and_delete_of_unindexed_child_row_keep_trigram_consistent(db: SessionDB):
ids = _seed(db)
db._conn.execute(
"UPDATE messages SET content = ? WHERE id = ?", ("改写后的内容", ids["kid"])
)
db._conn.execute("DELETE FROM messages WHERE id = ?", (ids["kid"],))
db._conn.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')"
)
assert _trigram_rowids(db) == {ids["root"], ids["cont"]}
def test_deferred_rebuild_does_not_reintroduce_children(db: SessionDB):
ids = _seed(db)
with db._lock:
db._reset_fts_index_to_empty(db._conn)
db._seed_fts_rebuild_markers(db._conn, force=True)
db._conn.commit()
while db.fts_rebuild_step():
pass
assert _trigram_rowids(db) == {ids["root"], ids["cont"]}
assert _fts_rowids(db) >= set(ids.values())
def test_full_rebuild_honours_exclusion(db: SessionDB):
ids = _seed(db)
db.rebuild_fts()
assert _trigram_rowids(db) == {ids["root"], ids["cont"]}
def test_v29_install_purges_child_rows_on_upgrade(tmp_path):
db_path = tmp_path / "state.db"
old = SessionDB(db_path=db_path)
if not old._trigram_available:
old.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
# Recreate the v29 (cron-only) view/trigger boundary.
old._conn.executescript(
"""
DROP TRIGGER messages_fts_trigram_insert;
DROP TRIGGER messages_fts_trigram_delete;
DROP TRIGGER messages_fts_trigram_update;
DROP VIEW messages_fts_trigram_src;
CREATE VIEW messages_fts_trigram_src AS
SELECT m.id, m.role, m.content, m.tool_name
FROM messages AS m JOIN sessions AS s ON s.id = m.session_id
WHERE m.role <> 'tool' AND s.source <> 'cron';
CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
AND EXISTS (SELECT 1 FROM sessions WHERE id = new.session_id AND source <> 'cron')
BEGIN
INSERT INTO messages_fts_trigram(rowid, content, tool_name)
VALUES (new.id, new.content, new.tool_name);
END;
"""
)
ids = _seed(old)
assert _trigram_rowids(old) == set(ids.values())
old._conn.execute("UPDATE schema_version SET version = 29")
old._conn.commit()
old.close()
migrated = SessionDB(db_path=db_path)
try:
assert _trigram_rowids(migrated) == {ids["root"], ids["cont"]}
assert migrated._conn.execute(
"SELECT version FROM schema_version"
).fetchone()[0] == SCHEMA_VERSION
migrated._conn.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')"
)
finally:
migrated.close()
def test_predicate_constants_agree():
assert "subagent" in FTS_TRIGRAM_EXCLUDED_SOURCES
assert "cron" in FTS_TRIGRAM_EXCLUDED_SOURCES
sql = fts_trigram_session_sql("s")
assert sql.startswith("s.source NOT IN (") and "s.model_config" in sql
+291
View File
@@ -0,0 +1,291 @@
"""Pattern-C gate: pure-read SessionDB methods must not take the writer lock.
The gateway shares ONE SessionDB across every agent. ``self._lock`` guards
the single writer connection — any read-only query executed under it
convoys every concurrent turn's persistence behind that reader (Pattern C
of the 2026-08 perf triage; #90734 shipped the unlocked-reader subset,
this gate covers the locked-reader subset).
``_read_ctx()`` exists precisely for reads: WAL reader from a bounded
pool, no lock, with a byte-identical fallback to the locked writer when
WAL is off. Reads have no reason to hold the writer lock.
The gate parses ``hermes_state.py`` with ``ast`` and flags any method
that (a) opens ``with self._lock:`` and (b) runs ONLY read statements
(SELECT/PRAGMA-read) on ``self._conn`` inside it — i.e. a pure reader
convoying on the writer lock. Methods that write under the lock are the
lock's legitimate users and pass. New violations fail with the method
name and the fix (route through ``_read_ctx()``).
``SessionDB`` itself is declared in ``hermes_state.py`` as
``class SessionDB(SessionSearchMixin, SessionSchemaMixin,
SessionPortabilityMixin)`` — its actual methods live across four files.
A gate that only opens ``hermes_state.py`` never sees a locked reader
declared in one of the three mixin files, so ``_ALL_STATE_SOURCES`` scans
each of them under their own class name.
Deliberately NOT flagged:
- methods that INSERT/UPDATE/DELETE/REPLACE under the lock (writers);
- read-modify-write methods (the read is ordered against its own write);
- ``_read_ctx``'s own writer-fallback (``yield self._conn`` — no execute);
- SELECTs on ``conn``/other objects (already pooled readers).
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_STATE_PY = _REPO_ROOT / "hermes_state.py"
# SessionDB's own class body lives in hermes_state.py; the rest of its
# methods come from these mixins (see module docstring). Each entry is
# (source file, class name to scan in that file).
_ALL_STATE_SOURCES: list[tuple[Path, str]] = [
(_STATE_PY, "SessionDB"),
(_REPO_ROOT / "hermes_state_search.py", "SessionSearchMixin"),
(_REPO_ROOT / "hermes_state_schema.py", "SessionSchemaMixin"),
(_REPO_ROOT / "hermes_state_portability.py", "SessionPortabilityMixin"),
]
_WRITE_RE = re.compile(
r"^\s*(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER|VACUUM|BEGIN|COMMIT|ANALYZE)\b",
re.IGNORECASE,
)
# PRAGMA is read-only EXCEPT the checkpoint/optimize family, which mutates
# the database file and legitimately belongs on the writer connection.
_PRAGMA_WRITE_RE = re.compile(
r"^\s*PRAGMA\s+(wal_checkpoint|optimize|incremental_vacuum|integrity_check)",
re.IGNORECASE,
)
_READ_RE = re.compile(r"^\s*(SELECT|PRAGMA)\b", re.IGNORECASE)
# Methods allowed to keep a pure-read body under the writer lock, each with
# the reason. Keep this list SHRINKING — never add to it without the same
# scrutiny a new blocking call would get.
_ALLOWED_LOCKED_READERS: dict[str, str] = {
# get_meta stays on the writer lock BY DESIGN (see its inline comment):
# fts_rebuild_step reads rebuild progress before entering a write
# transaction, and a pooled WAL reader sees only committed data — the
# writer's own just-staged meta updates would be invisible to it.
"get_meta": "read-your-writes: rebuild progress read before write txn",
}
def _first_sql_text(call: ast.Call) -> str | None:
"""Best-effort SQL text from an execute()'s first argument."""
if not call.args:
return None
arg = call.args[0]
text = None
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
text = arg.value
elif isinstance(arg, ast.JoinedStr):
parts = [
v.value for v in arg.values
if isinstance(v, ast.Constant) and isinstance(v.value, str)
]
text = "".join(parts)
if not text or not text.strip():
return None
return text.strip()
def _is_self_conn_execute(call: ast.Call, aliases: set[str]) -> bool:
"""Match ``self._conn.execute*`` and ``<alias>.execute*`` where the
alias was bound from ``self._conn`` (``conn = self._conn``)."""
f = call.func
if not (
isinstance(f, ast.Attribute)
and f.attr in ("execute", "executemany", "executescript")
):
return False
target = f.value
if (
isinstance(target, ast.Attribute)
and target.attr == "_conn"
and isinstance(target.value, ast.Name)
and target.value.id == "self"
):
return True
return isinstance(target, ast.Name) and target.id in aliases
def _collect_conn_aliases(method: ast.AST) -> set[str]:
"""Names bound from ``self._conn`` anywhere in the method body."""
aliases: set[str] = set()
for node in ast.walk(method):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Attribute):
v = node.value
if (
v.attr == "_conn"
and isinstance(v.value, ast.Name)
and v.value.id == "self"
):
for t in node.targets:
if isinstance(t, ast.Name):
aliases.add(t.id)
return aliases
def _is_self_lock_with(item: ast.withitem) -> bool:
ctx = item.context_expr
return (
isinstance(ctx, ast.Attribute)
and ctx.attr == "_lock"
and isinstance(ctx.value, ast.Name)
and ctx.value.id == "self"
)
def _scan_locked_readers(
state_py: "Path | None" = None, class_name: str = "SessionDB"
) -> list[str]:
target = state_py if state_py is not None else _STATE_PY
tree = ast.parse(target.read_text(encoding="utf-8"))
violations: list[str] = []
session_db = None
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == class_name:
session_db = node
break
assert session_db is not None, f"{class_name} class not found in {target}"
for method in session_db.body:
if not isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
aliases = _collect_conn_aliases(method)
for node in ast.walk(method):
if not isinstance(node, ast.With):
continue
if not any(_is_self_lock_with(i) for i in node.items):
continue
reads, writes, unknown = 0, 0, 0
for inner in ast.walk(node):
if isinstance(inner, ast.Call) and _is_self_conn_execute(inner, aliases):
word_full = _first_sql_text(inner)
word = word_full.split(None, 1)[0].upper() if word_full else None
if word is None:
# SQL held in a variable or built f-string: the
# scanner cannot prove it reads. A lock block whose
# ONLY statements are unprovable is still flagged
# below — writers name their verbs in literals
# throughout this file, so opacity correlates with
# composed SELECTs, and silently skipping these is
# how 5 readers hid from the first version of this
# gate.
unknown += 1
elif _PRAGMA_WRITE_RE.match(word_full or ""):
writes += 1
elif _WRITE_RE.match(word):
writes += 1
elif _READ_RE.match(word):
reads += 1
else:
unknown += 1
# Method calls under the lock may write internally
# (e.g. self._execute_write, cursor ops) — treat any
# self.<something>() as potentially writing.
elif isinstance(inner, ast.Call):
f = inner.func
if (
isinstance(f, ast.Attribute)
and isinstance(f.value, ast.Name)
and f.value.id == "self"
and (
"write" in f.attr
or "commit" in f.attr
or f.attr.startswith(("set_", "record_", "insert_",
"update_", "delete_", "clear_"))
)
):
writes += 1
if writes == 0 and (reads > 0 or unknown > 0):
if method.name not in _ALLOWED_LOCKED_READERS:
kind = "pure-read" if unknown == 0 else "no-proven-write"
violations.append(
f"{method.name} (line {node.lineno}): {kind} "
f"body under `with self._lock:` — route through "
f"_read_ctx() instead (or add a justified "
f"allowlist entry)"
)
return violations
def _scan_all_state_sources() -> list[str]:
"""Run ``_scan_locked_readers`` over every file that contributes methods
to ``SessionDB`` — the class body in ``hermes_state.py`` plus each mixin
it inherits from (see module docstring). Violations are prefixed with
their source filename since methods can share names across mixins.
"""
violations: list[str] = []
for path, class_name in _ALL_STATE_SOURCES:
for v in _scan_locked_readers(path, class_name):
violations.append(f"{path.name}: {v}")
return violations
class TestNoPureReadersUnderWriterLock:
def test_no_locked_pure_readers(self):
violations = _scan_all_state_sources()
assert violations == [], (
"Pure-read SessionDB methods holding the writer lock "
"(Pattern C — every concurrent turn's persistence convoys "
"behind these reads):\n " + "\n ".join(violations)
)
def test_gate_detects_a_locked_reader(self, tmp_path):
"""Sabotage self-check: the scanner must flag a synthetic violation."""
sabotage = (
"class SessionDB:\n"
" def innocent_writer(self):\n"
" with self._lock:\n"
" self._conn.execute(\"UPDATE t SET x = 1\")\n"
" def guilty_reader(self):\n"
" with self._lock:\n"
" return self._conn.execute(\"SELECT 1\").fetchone()\n"
" def guilty_alias_reader(self):\n"
" with self._lock:\n"
" conn = self._conn\n"
" return conn.execute(\"SELECT 2\").fetchone()\n"
" def guilty_variable_sql(self, query):\n"
" with self._lock:\n"
" return self._conn.execute(query).fetchall()\n"
" def innocent_variable_writer(self, query):\n"
" with self._lock:\n"
" self._conn.execute(query)\n"
" self._conn.execute(\"UPDATE t SET x = 2\")\n"
)
p = tmp_path / "fake_state.py"
p.write_text(sabotage, encoding="utf-8")
violations = _scan_locked_readers(p)
flagged = {v.split(" ")[0] for v in violations}
assert flagged == {
"guilty_reader", "guilty_alias_reader", "guilty_variable_sql"
}, violations
def test_scan_all_state_sources_visits_every_mixin_file(self, tmp_path):
"""Sabotage self-check for the multi-file scope itself: a locked
reader planted in a MIXIN file (not hermes_state.py) must still be
caught. Guards against the gate's scope silently narrowing back to
one file — exactly how the real 2026-08 gap (9 locked readers across
three mixin files, invisible to the single-file scanner) happened.
"""
mixin_sabotage = (
"class FakeMixin:\n"
" def guilty_mixin_reader(self):\n"
" with self._lock:\n"
" return self._conn.execute(\"SELECT 1\").fetchone()\n"
)
p = tmp_path / "fake_mixin.py"
p.write_text(mixin_sabotage, encoding="utf-8")
violations = [
f"{p.name}: {v}" for v in _scan_locked_readers(p, "FakeMixin")
]
assert any("guilty_mixin_reader" in v for v in violations), violations
+92
View File
@@ -0,0 +1,92 @@
"""Retry of transient 'no more rows available' engine errors (#74934 port).
Under dual gateway/agent WAL contention (FTS5 trigram sync holding the
write lock on large appends), the SQLite engine can raise a transient
'no more rows available' error. The exception CLASS varies with the
SQLite build — some surface it as ``sqlite3.InterfaceError``, which is a
sibling of ``DatabaseError`` (not a subclass) and therefore escaped both
existing retry branches in ``_execute_write`` on attempt 0, killing the
turn as ``session_persistence_failed`` while the identical write would
have succeeded milliseconds later.
The fix is message-scoped, not class-scoped: any ``sqlite3.Error`` whose
text contains 'no more rows available' retries within the existing
deadline/patience loop; every other error propagates untouched.
"""
import sqlite3
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path, monkeypatch):
# Keep retries fast: tiny jitter, short-but-sufficient patience.
monkeypatch.setattr(SessionDB, "_WRITE_PATIENCE_S", 2.0)
monkeypatch.setattr(SessionDB, "_WRITE_RETRY_MIN_S", 0.001)
monkeypatch.setattr(SessionDB, "_WRITE_RETRY_MAX_S", 0.005)
d = SessionDB(db_path=tmp_path / "state.db")
yield d
d.close()
class TestNoMoreRowsRetry:
def test_transient_interface_error_is_retried_to_success(self, db):
"""InterfaceError('no more rows available') must be retried inside
the deadline/patience loop and succeed once the contention clears."""
calls = {"n": 0}
def flaky(conn):
calls["n"] += 1
if calls["n"] <= 3:
raise sqlite3.InterfaceError("no more rows available")
conn.execute(
"INSERT INTO state_meta (key, value) VALUES ('nmr', 'ok') "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
)
return "done"
assert db._execute_write(flaky) == "done"
assert calls["n"] == 4
assert db.get_meta("nmr") == "ok"
def test_unrelated_interface_error_propagates_immediately(self, db):
"""The catch-all is message-scoped: an InterfaceError with any other
text must escape on the first attempt, not be swallowed/retried."""
calls = {"n": 0}
def broken(conn):
calls["n"] += 1
raise sqlite3.InterfaceError("bad parameter or other API misuse")
with pytest.raises(sqlite3.InterfaceError, match="bad parameter"):
db._execute_write(broken)
assert calls["n"] == 1
def test_no_more_rows_via_database_error_is_retried(self, db):
"""Some builds raise the same transient message through the generic
DatabaseError class — it must ride the same retry loop instead of
being misrouted into the FTS-corruption rebuild path."""
calls = {"n": 0}
def flaky(conn):
calls["n"] += 1
if calls["n"] <= 2:
raise sqlite3.DatabaseError("no more rows available")
return "ok"
assert db._execute_write(flaky) == "ok"
assert calls["n"] == 3
def test_exhausted_patience_propagates_the_transient_error(self, db, monkeypatch):
"""If contention never clears within the patience budget, the
original error must surface rather than looping forever."""
monkeypatch.setattr(SessionDB, "_WRITE_PATIENCE_S", 0.05)
def always(conn):
raise sqlite3.InterfaceError("no more rows available")
with pytest.raises(sqlite3.InterfaceError, match="no more rows"):
db._execute_write(always)
@@ -0,0 +1,257 @@
"""Cross-process ordering for asynchronous session Git metadata probes."""
from __future__ import annotations
import sqlite3
import threading
from hermes_state import SCHEMA_VERSION, SessionDB
def _open_pair(tmp_path):
path = tmp_path / "state.db"
first = SessionDB(db_path=path)
second = SessionDB(db_path=path)
first.create_session("session", "desktop", cwd="/repo/A")
return first, second
def _require_generation(value: int | None) -> int:
assert isinstance(value, int) and not isinstance(value, bool)
return value
def test_delayed_probe_cannot_overwrite_newer_a_b_a_claim(tmp_path):
first, second = _open_pair(tmp_path)
release_old = threading.Event()
old_finished = threading.Event()
old_result = []
try:
old_generation = _require_generation(
first.update_session_cwd("session", "/repo/A")
)
def publish_old_probe():
assert release_old.wait(5)
old_result.append(
first.publish_session_git_metadata(
"session",
"/repo/A",
old_generation,
"stale-branch",
"/repo/stale-root",
)
)
old_finished.set()
worker = threading.Thread(target=publish_old_probe)
worker.start()
second.update_session_cwd("session", "/repo/B")
new_generation = _require_generation(
second.update_session_cwd("session", "/repo/A")
)
assert new_generation > old_generation
assert second.publish_session_git_metadata(
"session",
"/repo/A",
new_generation,
"new-branch",
"/repo/new-root",
)
release_old.set()
assert old_finished.wait(5)
worker.join(timeout=5)
assert not worker.is_alive()
assert old_result == [False]
row = second.get_session("session")
assert row is not None
assert row["cwd"] == "/repo/A"
assert row["git_branch"] == "new-branch"
assert row["git_repo_root"] == "/repo/new-root"
finally:
release_old.set()
first.close()
second.close()
def test_repeated_same_cwd_claim_invalidates_older_probe(tmp_path):
first, second = _open_pair(tmp_path)
try:
old_generation = _require_generation(
first.update_session_cwd("session", "/repo/A")
)
new_generation = _require_generation(
second.update_session_cwd("session", "/repo/A")
)
assert new_generation > old_generation
assert second.publish_session_git_metadata(
"session", "/repo/A", new_generation, "new", "/repo/A"
)
assert not first.publish_session_git_metadata(
"session", "/repo/A", old_generation, "old", "/repo/old"
)
row = second.get_session("session")
assert row is not None
assert row["git_branch"] == "new"
finally:
first.close()
second.close()
def test_cwd_move_clears_metadata_in_same_claim(tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("session", "desktop", cwd="/repo/A")
generation = _require_generation(
db.update_session_cwd("session", "/repo/A")
)
assert db.publish_session_git_metadata(
"session", "/repo/A", generation, "main", "/repo/A"
)
moved_generation = _require_generation(
db.update_session_cwd("session", "/repo/B")
)
row = db.get_session("session")
assert row is not None
assert moved_generation > generation
assert row["cwd"] == "/repo/B"
assert row["git_branch"] is None
assert row["git_repo_root"] is None
finally:
db.close()
def test_explicit_move_replaces_metadata_and_claims_generation(tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("session", "desktop", cwd="/repo/A")
initial_generation = _require_generation(
db.update_session_cwd(
"session",
"/repo/A",
git_branch="main",
git_repo_root="/repo/A",
)
)
moved_generation = _require_generation(
db.update_session_cwd(
"session",
"/outside-git",
replace_git_meta=True,
)
)
row = db.get_session("session")
assert row is not None
assert moved_generation > initial_generation
assert row["cwd"] == "/outside-git"
assert row["git_branch"] is None
assert row["git_repo_root"] is None
finally:
db.close()
def test_failed_new_probe_still_invalidates_older_worker(tmp_path):
first, second = _open_pair(tmp_path)
try:
baseline = _require_generation(
first.update_session_cwd("session", "/repo/A")
)
assert first.publish_session_git_metadata(
"session", "/repo/A", baseline, "baseline", "/repo/A"
)
old_generation = _require_generation(
first.update_session_cwd("session", "/repo/A")
)
second.update_session_cwd("session", "/repo/A")
assert not first.publish_session_git_metadata(
"session", "/repo/A", old_generation, "stale", "/repo/stale"
)
row = second.get_session("session")
assert row is not None
assert row["git_branch"] == "baseline"
assert row["git_repo_root"] == "/repo/A"
finally:
first.close()
second.close()
def test_generation_authority_is_scoped_to_each_profile_database(tmp_path):
first = SessionDB(db_path=tmp_path / "profile-a.db")
second = SessionDB(db_path=tmp_path / "profile-b.db")
try:
first.create_session("same-id", "desktop", cwd="/a")
second.create_session("same-id", "desktop", cwd="/b")
first_generation = _require_generation(
first.update_session_cwd("same-id", "/a")
)
second_generation = _require_generation(
second.update_session_cwd("same-id", "/b")
)
assert first.publish_session_git_metadata(
"same-id", "/a", first_generation, "a", "/a"
)
assert second.publish_session_git_metadata(
"same-id", "/b", second_generation, "b", "/b"
)
first_row = first.get_session("same-id")
second_row = second.get_session("same-id")
assert first_row is not None
assert second_row is not None
assert first_row["git_branch"] == "a"
assert second_row["git_branch"] == "b"
finally:
first.close()
second.close()
def test_legacy_sessions_table_reconciles_generation_column(tmp_path):
path = tmp_path / "state.db"
SessionDB(db_path=path).close()
conn = sqlite3.connect(path)
try:
conn.execute("ALTER TABLE sessions DROP COLUMN git_metadata_generation")
conn.execute("UPDATE schema_version SET version = 25")
conn.commit()
finally:
conn.close()
reopened = SessionDB(db_path=path)
try:
verify = sqlite3.connect(path)
try:
columns = {
row[1]
for row in verify.execute("PRAGMA table_info('sessions')")
}
finally:
verify.close()
assert "git_metadata_generation" in columns
assert reopened._conn.execute(
"SELECT version FROM schema_version"
).fetchone()[0] == SCHEMA_VERSION
reopened.create_session("session", "desktop", cwd="/repo")
assert reopened.update_session_cwd("session", "/repo") == 1
finally:
reopened.close()
def test_compact_session_rows_do_not_expose_internal_generation(tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("session", "desktop", cwd="/repo")
db.update_session_cwd("session", "/repo")
rows = db.list_sessions_rich(compact_rows=True)
assert len(rows) == 1
assert "git_metadata_generation" not in rows[0]
finally:
db.close()
@@ -0,0 +1,170 @@
"""Unconditional session_model_usage PK heal (#73823, salvage of #73838).
Installs whose state.db reached ``schema_version >= 22`` before the
``task`` dimension was added carry a 5-column PRIMARY KEY on
``session_model_usage``. The column reconciler ADDs ``task`` as a bare
nullable, but SQLite cannot ALTER a primary key, and the version-gated
v22 rebuild is unreachable (``current_version < 22`` already false), so
the composite 6-column key never lands. Every upsert in
``_record_model_usage`` then fails with "ON CONFLICT clause does not
match any PRIMARY KEY or UNIQUE constraint", aborting the enclosing
write transaction — token/cost accounting permanently dead.
``_heal_session_model_usage_pk`` runs unconditionally on every open
(same pattern as ``_heal_gateway_routing_pk``) and rebuilds the table
once, inside an FK-off window (OR IGNORE does not suppress FK
violations and the connection enables foreign_keys before init).
"""
import sqlite3
import pytest
from hermes_state import SessionDB
from hermes_state_common import SCHEMA_VERSION
LEGACY_SQL = """
CREATE TABLE session_model_usage (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
model TEXT NOT NULL,
billing_provider TEXT NOT NULL DEFAULT '',
billing_base_url TEXT NOT NULL DEFAULT '',
billing_mode TEXT NOT NULL DEFAULT '',
api_call_count INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
estimated_cost_usd REAL NOT NULL DEFAULT 0,
actual_cost_usd REAL NOT NULL DEFAULT 0,
cost_status TEXT,
cost_source TEXT,
first_seen REAL,
last_seen REAL,
PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode)
)
"""
def _make_stale_v22_db(tmp_path, usage_rows=(), sessions=("s1",)):
"""Build a state.db in the stale-v22+ shape: current schema everywhere,
but session_model_usage carrying the legacy 5-column PK with ``task``
reconciler-appended OUTSIDE the key, and schema_version already at
current — so the version-gated v22 rebuild can never run."""
db_path = tmp_path / "state.db"
# Born-current DB for everything else...
db = SessionDB(db_path=db_path)
for sid in sessions:
db.create_session(sid, "cli")
db.close()
# ...then regress session_model_usage to the legacy shape.
conn = sqlite3.connect(db_path)
conn.execute("DROP TABLE session_model_usage")
conn.execute(LEGACY_SQL)
# Mimic the column reconciler: task appended OUTSIDE the primary key.
conn.execute('ALTER TABLE session_model_usage ADD COLUMN "task" TEXT')
conn.executemany(
"INSERT INTO session_model_usage "
"(session_id, model, input_tokens, output_tokens) VALUES (?, ?, ?, ?)",
list(usage_rows),
)
# Version already current: proves the heal does not depend on the gate.
conn.execute("UPDATE schema_version SET version = ?", (SCHEMA_VERSION,))
conn.commit()
conn.close()
return db_path
def _pk_cols(db):
rows = db._conn.execute(
'PRAGMA table_info("session_model_usage")'
).fetchall()
return sorted(r["name"] for r in rows if r["pk"])
class TestSessionModelUsagePkHeal:
def test_stale_v22_pk_rebuilt_and_accounting_restored(self, tmp_path):
"""The broken-PK table is rebuilt on open even though schema_version
is already current, and the usage upsert works again."""
db_path = _make_stale_v22_db(
tmp_path, usage_rows=[("s1", "m-old", 10, 20)]
)
db = SessionDB(db_path=db_path)
try:
assert "task" in _pk_cols(db)
# Existing rows survive the rebuild (task backfilled to '').
row = db._conn.execute(
"SELECT task, input_tokens FROM session_model_usage "
"WHERE session_id='s1' AND model='m-old'"
).fetchone()
assert row is not None
assert row["task"] == ""
assert row["input_tokens"] == 10
# The killed write path works again: the upsert used to abort
# the whole transaction with an ON CONFLICT mismatch.
db.update_token_counts(
"s1", input_tokens=5, output_tokens=7,
model="m-new", billing_provider="p", api_call_count=1,
)
row = db._conn.execute(
"SELECT input_tokens FROM session_model_usage "
"WHERE session_id='s1' AND model='m-new'"
).fetchone()
assert row is not None and row["input_tokens"] == 5
finally:
db.close()
def test_orphan_rows_survive_fk_enforcement(self, tmp_path):
"""The rebuild copies rows inside an FK-off window: an orphaned
usage row (session pruned while accounting was broken) must not
abort the heal — OR IGNORE does NOT suppress FK violations."""
db_path = _make_stale_v22_db(
tmp_path,
usage_rows=[("s1", "m1", 1, 1), ("ghost-session", "m1", 2, 2)],
)
db = SessionDB(db_path=db_path)
try:
assert "task" in _pk_cols(db)
rows = db._conn.execute(
"SELECT session_id FROM session_model_usage ORDER BY session_id"
).fetchall()
assert [r["session_id"] for r in rows] == ["ghost-session", "s1"]
# FK enforcement is restored after the heal window.
assert db._conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1
finally:
db.close()
def test_healthy_db_is_a_noop(self, tmp_path):
"""A DB born with the composite PK is left untouched (idempotence)."""
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("s1", "cli")
db.update_token_counts(
"s1", input_tokens=3, model="m", billing_provider="p",
api_call_count=1,
)
assert "task" in _pk_cols(db)
# Re-running the heal directly is a no-op.
cur = db._conn.cursor()
db._heal_session_model_usage_pk(cur)
row = db._conn.execute(
"SELECT input_tokens FROM session_model_usage "
"WHERE session_id='s1'"
).fetchone()
assert row is not None and row["input_tokens"] == 3
finally:
db.close()
def test_no_legacy_leftover_table(self, tmp_path):
"""The rename-copy-drop leaves no *_legacy_pk residue behind."""
db_path = _make_stale_v22_db(tmp_path, usage_rows=[("s1", "m1", 1, 1)])
db = SessionDB(db_path=db_path)
try:
left = db._conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='session_model_usage_legacy_pk'"
).fetchone()
assert left is None
finally:
db.close()
+669
View File
@@ -0,0 +1,669 @@
"""Cross-process session turn lease behavior (#84234)."""
from __future__ import annotations
import os
import sqlite3
import threading
import time
from types import SimpleNamespace
import pytest
import hermes_state
from hermes_state import SessionDB, SessionTurnLeaseLostError
def test_turn_lease_serializes_separate_session_db_instances(tmp_path):
"""A second process-shaped DB handle waits for the current turn owner."""
path = tmp_path / "state.db"
first = SessionDB(path)
second = SessionDB(path)
first.create_session("shared", source="test")
first_holder = f"pid={os.getpid()}:turn=first"
second_holder = f"pid={os.getpid()}:turn=second"
assert first.try_acquire_session_turn_lease(
"shared", first_holder, ttl_seconds=5
)
released = threading.Event()
def release_first():
time.sleep(0.2)
first.release_session_turn_lease("shared", first_holder)
released.set()
thread = threading.Thread(target=release_first, daemon=True)
thread.start()
started = time.monotonic()
try:
assert second.acquire_session_turn_lease(
"shared",
second_holder,
ttl_seconds=5,
wait_seconds=2,
poll_interval_seconds=0.02,
)
finally:
thread.join(timeout=2)
assert released.is_set()
assert time.monotonic() - started >= 0.15
second.release_session_turn_lease("shared", second_holder)
def test_turn_lease_is_scoped_to_conversation_root(tmp_path):
"""Compression descendants share one durable serialization domain."""
db = SessionDB(tmp_path / "state.db")
db.create_session("root", source="test")
db.end_session("root", "compression")
db.create_session("child", source="test", parent_session_id="root")
root_holder = f"pid={os.getpid()}:turn=root"
child_holder = f"pid={os.getpid()}:turn=child"
assert db.try_acquire_session_turn_lease(
"root", root_holder, ttl_seconds=5
)
assert not db.try_acquire_session_turn_lease(
"child", child_holder, ttl_seconds=5
)
db.release_session_turn_lease("child", root_holder)
def test_turn_lease_does_not_serialize_delegate_child_with_parent(tmp_path):
"""Only compression continuation segments share a conversation lease."""
db = SessionDB(tmp_path / "state.db")
db.create_session("parent", source="test")
db.create_session(
"delegate",
source="delegate",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
parent_holder = f"pid={os.getpid()}:turn=parent"
delegate_holder = f"pid={os.getpid()}:turn=delegate"
assert db.try_acquire_session_turn_lease(
"parent", parent_holder, ttl_seconds=5
)
assert db.try_acquire_session_turn_lease(
"delegate", delegate_holder, ttl_seconds=5
)
def test_turn_lease_walks_compression_child_that_inherited_fork_markers(tmp_path):
"""Inherited ``_delegate_from`` / ``_branched_from`` must not stop the walk.
``publish_compression_child`` copies ``model_config`` verbatim, so a
delegate or branch continuation carries a marker pointing at some other
session. Presence-only fork detection would key the child separately:
the holder still owns the parent-key lease, but the first refresh after
rotation looks up the child id and fail-closes with a hard interrupt.
"""
db = SessionDB(tmp_path / "state.db")
db.create_session("original-parent", source="test")
db.create_session(
"delegate",
source="delegate",
parent_session_id="original-parent",
model_config={"_delegate_from": "original-parent"},
)
db.end_session("delegate", "compression")
db.create_session(
"delegate-continuation",
source="delegate",
parent_session_id="delegate",
model_config={"_delegate_from": "original-parent"},
)
db.create_session(
"branch",
source="test",
parent_session_id="original-parent",
model_config={"_branched_from": "original-parent"},
)
db.end_session("branch", "compression")
db.create_session(
"branch-continuation",
source="test",
parent_session_id="branch",
model_config={"_branched_from": "original-parent"},
)
assert db._session_turn_lease_key("delegate-continuation") == "delegate"
assert db._session_turn_lease_key("branch-continuation") == "branch"
delegate_holder = f"pid={os.getpid()}:turn=delegate"
assert db.try_acquire_session_turn_lease(
"delegate", delegate_holder, ttl_seconds=5
)
assert not db.try_acquire_session_turn_lease(
"delegate-continuation",
f"pid={os.getpid()}:turn=delegate-child",
ttl_seconds=5,
)
assert db.refresh_session_turn_lease(
"delegate-continuation", delegate_holder, ttl_seconds=5
)
branch_holder = f"pid={os.getpid()}:turn=branch"
assert db.try_acquire_session_turn_lease(
"branch", branch_holder, ttl_seconds=5
)
assert not db.try_acquire_session_turn_lease(
"branch-continuation",
f"pid={os.getpid()}:turn=branch-child",
ttl_seconds=5,
)
assert db.refresh_session_turn_lease(
"branch-continuation", branch_holder, ttl_seconds=5
)
original_holder = f"pid={os.getpid()}:turn=original"
assert db.try_acquire_session_turn_lease(
"original-parent", original_holder, ttl_seconds=5
)
db.release_session_turn_lease("delegate-continuation", delegate_holder)
db.release_session_turn_lease("branch-continuation", branch_holder)
db.release_session_turn_lease("original-parent", original_holder)
def test_turn_lease_write_txn_does_not_trust_fail_open_key_helper(
tmp_path, monkeypatch: pytest.MonkeyPatch
):
"""Acquire/refresh/release walk inside the write txn.
The old helper swallowed get_session failures and returned the child id.
P2 then proceeded to acquire; the write succeeded under that child key
and the first working refresh walked to the parent and hard-interrupted.
Poisoning the outer helper must not change the conversation key.
"""
db = SessionDB(tmp_path / "state.db")
db.create_session(
"delegate",
source="delegate",
model_config={"_delegate_from": "original-parent"},
)
db.end_session("delegate", "compression")
db.create_session(
"delegate-continuation",
source="delegate",
parent_session_id="delegate",
model_config={"_delegate_from": "original-parent"},
)
monkeypatch.setattr(db, "_session_turn_lease_key", lambda sid: sid)
holder = f"pid={os.getpid()}:turn=delegate"
assert db.try_acquire_session_turn_lease(
"delegate", holder, ttl_seconds=5
)
assert not db.try_acquire_session_turn_lease(
"delegate-continuation",
f"pid={os.getpid()}:turn=child",
ttl_seconds=5,
)
assert db.refresh_session_turn_lease(
"delegate-continuation", holder, ttl_seconds=5
)
db.release_session_turn_lease("delegate-continuation", holder)
assert db.try_acquire_session_turn_lease(
"delegate", f"pid={os.getpid()}:turn=next", ttl_seconds=5
)
def test_turn_lease_retries_locked_in_txn_key_walk(
tmp_path, monkeypatch: pytest.MonkeyPatch
):
"""A locked lineage walk must retry, not INSERT under the child id."""
db = SessionDB(tmp_path / "state.db")
db.create_session(
"delegate",
source="delegate",
model_config={"_delegate_from": "original-parent"},
)
db.end_session("delegate", "compression")
db.create_session(
"delegate-continuation",
source="delegate",
parent_session_id="delegate",
model_config={"_delegate_from": "original-parent"},
)
attempts = {"n": 0}
original = db._session_turn_lease_key_on_conn
def flaky_walk(conn, session_id):
attempts["n"] += 1
if attempts["n"] == 1:
raise sqlite3.OperationalError("database is locked")
return original(conn, session_id)
monkeypatch.setattr(db, "_session_turn_lease_key_on_conn", flaky_walk)
holder = f"pid={os.getpid()}:turn=delegate"
assert db.try_acquire_session_turn_lease(
"delegate-continuation", holder, ttl_seconds=5
)
assert attempts["n"] >= 2
monkeypatch.setattr(db, "_session_turn_lease_key_on_conn", original)
assert not db.try_acquire_session_turn_lease(
"delegate", f"pid={os.getpid()}:turn=other", ttl_seconds=5
)
assert db.refresh_session_turn_lease("delegate", holder, ttl_seconds=5)
db.release_session_turn_lease("delegate-continuation", holder)
def test_turn_lease_refresh_and_release_are_owner_fenced(tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
current_holder = f"pid={os.getpid()}:turn=current"
stale_holder = f"pid={os.getpid()}:turn=stale"
next_holder = f"pid={os.getpid()}:turn=next"
assert db.try_acquire_session_turn_lease(
"shared", current_holder, ttl_seconds=5
)
assert not db.refresh_session_turn_lease(
"shared", stale_holder, ttl_seconds=5
)
db.release_session_turn_lease("shared", stale_holder)
assert not db.try_acquire_session_turn_lease(
"shared", next_holder, ttl_seconds=5
)
assert db.refresh_session_turn_lease(
"shared", current_holder, ttl_seconds=5
)
db.release_session_turn_lease("shared", current_holder)
assert db.try_acquire_session_turn_lease(
"shared", next_holder, ttl_seconds=5
)
def test_expired_turn_lease_is_reclaimed(tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
assert db.try_acquire_session_turn_lease(
"shared", "legacy-holder", ttl_seconds=0.05
)
time.sleep(0.15)
assert db.try_acquire_session_turn_lease(
"shared", "pid=202:turn=reclaimer", ttl_seconds=5
)
def test_acquire_turn_lease_notifies_wait_callback(tmp_path):
"""Waiters get a progress callback while another holder owns the lease."""
path = tmp_path / "state.db"
first = SessionDB(path)
second = SessionDB(path)
first.create_session("shared", source="test")
first_holder = f"pid={os.getpid()}:turn=first"
second_holder = f"pid={os.getpid()}:turn=second"
assert first.try_acquire_session_turn_lease(
"shared", first_holder, ttl_seconds=5
)
notices = []
def release_first():
time.sleep(0.12)
first.release_session_turn_lease("shared", first_holder)
thread = threading.Thread(target=release_first, daemon=True)
thread.start()
try:
assert second.acquire_session_turn_lease(
"shared",
second_holder,
ttl_seconds=5,
wait_seconds=2,
poll_interval_seconds=0.02,
on_wait=notices.append,
wait_notice_interval_seconds=0.05,
)
finally:
thread.join(timeout=2)
assert notices
assert notices[0] < 0.05
second.release_session_turn_lease("shared", second_holder)
def test_acquire_turn_lease_honors_should_abort(tmp_path):
"""Waiters stop immediately when should_abort() returns True."""
path = tmp_path / "state.db"
first = SessionDB(path)
second = SessionDB(path)
first.create_session("shared", source="test")
first_holder = f"pid={os.getpid()}:turn=first"
second_holder = f"pid={os.getpid()}:turn=second"
assert first.try_acquire_session_turn_lease(
"shared", first_holder, ttl_seconds=60
)
abort_checks = {"count": 0}
def should_abort():
abort_checks["count"] += 1
return True
started = time.monotonic()
assert not second.acquire_session_turn_lease(
"shared",
second_holder,
wait_seconds=30,
poll_interval_seconds=0.05,
should_abort=should_abort,
)
assert time.monotonic() - started < 1.0
assert abort_checks["count"] >= 1
first.release_session_turn_lease("shared", first_holder)
def test_acquire_turn_lease_retries_sqlite_lock(tmp_path, monkeypatch):
"""Write-lock exhaustion is contended, not a hard abort of the wait."""
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
holder = f"pid={os.getpid()}:turn=waiter"
attempts = {"n": 0}
original = db.try_acquire_session_turn_lease
def flaky_acquire(*args, **kwargs):
attempts["n"] += 1
if attempts["n"] == 1:
raise sqlite3.OperationalError(
"database is locked (another Hermes process held the "
"state.db write lock for over 20s)"
)
return original(*args, **kwargs)
monkeypatch.setattr(db, "try_acquire_session_turn_lease", flaky_acquire)
assert db.acquire_session_turn_lease(
"shared",
holder,
wait_seconds=2,
poll_interval_seconds=0.02,
acquire_patience_s=0.05,
)
assert attempts["n"] >= 2
db.release_session_turn_lease("shared", holder)
def test_acquire_turn_lease_reraises_non_lock_sqlite_error(tmp_path, monkeypatch):
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
def disk_full(*args, **kwargs):
raise sqlite3.OperationalError("database or disk is full")
monkeypatch.setattr(db, "try_acquire_session_turn_lease", disk_full)
with pytest.raises(sqlite3.OperationalError, match="disk is full"):
db.acquire_session_turn_lease(
"shared",
f"pid={os.getpid()}:turn=waiter",
wait_seconds=1,
poll_interval_seconds=0.02,
)
def test_non_expired_turn_lease_from_dead_pid_is_reclaimed(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A holder whose structured pid= no longer exists can be reclaimed early."""
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
dead_holder = "pid=424242:turn=dead:platform=test"
assert db.try_acquire_session_turn_lease(
"shared", dead_holder, ttl_seconds=300
) is True
probed: list[int] = []
def pid_exists(pid: int) -> bool:
probed.append(pid)
return False
monkeypatch.setattr(
hermes_state, "psutil", SimpleNamespace(pid_exists=pid_exists)
)
fresh_holder = "pid=525252:turn=fresh:platform=test"
assert db.try_acquire_session_turn_lease(
"shared", fresh_holder, ttl_seconds=300
) is True
assert probed == [424242]
def test_turn_lease_fences_stale_transcript_flush_after_reclaim(tmp_path):
"""A lost holder cannot persist after B has taken the conversation.
Refresh-loss interrupt is cooperative; the lease itself must reject the
late append inside the same SQLite write transaction.
"""
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
stale_holder = f"pid={os.getpid()}:turn=stale"
next_holder = f"pid={os.getpid()}:turn=next"
assert db.try_acquire_session_turn_lease(
"shared", stale_holder, ttl_seconds=5
)
assert db.append_messages_batch(
"shared",
[{"role": "user", "content": "stale-owned"}],
turn_lease_holder=stale_holder,
) == 1
db.release_session_turn_lease("shared", stale_holder)
assert db.try_acquire_session_turn_lease(
"shared", next_holder, ttl_seconds=5
)
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"shared",
[{"role": "assistant", "content": "late stale reply"}],
turn_lease_holder=stale_holder,
)
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_message(
"shared",
"assistant",
"late stale single-row",
turn_lease_holder=stale_holder,
)
assert db.append_messages_batch(
"shared",
[{"role": "assistant", "content": "next reply"}],
turn_lease_holder=next_holder,
) == 1
assert [m["content"] for m in db.get_messages("shared")] == [
"stale-owned",
"next reply",
]
db.release_session_turn_lease("shared", next_holder)
def test_turn_lease_revives_expired_row_still_owned_by_writer(tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
holder = f"pid={os.getpid()}:turn=owner"
assert db.try_acquire_session_turn_lease("shared", holder, ttl_seconds=0.05)
time.sleep(0.12)
assert db.append_messages_batch(
"shared",
[{"role": "assistant", "content": "after ttl"}],
turn_lease_holder=holder,
turn_lease_ttl_seconds=0.2,
) == 1
assert not db.try_acquire_session_turn_lease(
"shared", f"pid={os.getpid()}:turn=contender", ttl_seconds=5
)
def test_turn_lease_fences_flush_when_row_is_absent(tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
holder = f"pid={os.getpid()}:turn=owner"
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"shared",
[{"role": "assistant", "content": "after release"}],
turn_lease_holder=holder,
)
assert db.get_messages("shared") == []
def test_turn_lease_fence_walks_compression_child_to_root(tmp_path):
"""A parent-key holder still fences writes against the rotated tip."""
db = SessionDB(tmp_path / "state.db")
db.create_session("root", source="test")
db.end_session("root", "compression")
db.create_session("child", source="test", parent_session_id="root")
root_holder = f"pid={os.getpid()}:turn=root"
stale_holder = f"pid={os.getpid()}:turn=stale"
assert db.try_acquire_session_turn_lease(
"root", root_holder, ttl_seconds=5
)
assert db.append_messages_batch(
"child",
[{"role": "user", "content": "owner on tip"}],
turn_lease_holder=root_holder,
) == 1
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"child",
[{"role": "assistant", "content": "impostor"}],
turn_lease_holder=stale_holder,
)
db.release_session_turn_lease("child", root_holder)
def test_lost_turn_lease_flush_fails_fast_without_patience_retry(
tmp_path, monkeypatch: pytest.MonkeyPatch
):
"""Sibling of test_a_lost_compression_lease_still_fails_fast.
SessionTurnLeaseLostError is permanent fencing, not a live-busy signal.
Retrying it would burn transcript write patience and still fail.
"""
db = SessionDB(tmp_path / "state.db")
db.create_session("shared", source="test")
stale_holder = f"pid={os.getpid()}:turn=stale"
next_holder = f"pid={os.getpid()}:turn=next"
assert db.try_acquire_session_turn_lease(
"shared", stale_holder, ttl_seconds=5
)
db.release_session_turn_lease("shared", stale_holder)
assert db.try_acquire_session_turn_lease(
"shared", next_holder, ttl_seconds=5
)
sleeps = []
original = db._sleep_before_write_retry
def track_sleep(deadline, patience_s):
sleeps.append(patience_s)
return original(deadline, patience_s)
monkeypatch.setattr(db, "_sleep_before_write_retry", track_sleep)
monkeypatch.setattr(SessionDB, "_COMPRESSION_BUSY_WAIT_S", 5.0)
started = time.monotonic()
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"shared",
[{"role": "assistant", "content": "late stale reply"}],
turn_lease_holder=stale_holder,
)
assert time.monotonic() - started < 0.5
assert sleeps == []
assert db.get_messages("shared") == []
db.release_session_turn_lease("shared", next_holder)
def test_turn_lease_fence_walks_continuation_that_inherited_fork_markers(tmp_path):
"""Owner flush on a rotated tip must use the parent-key lease.
Presence-only ``_delegate_from`` / ``_branched_from`` detection would
treat the continuation as its own conversation. The presented parent
holder would then miss the row and fail-close a still-valid owner.
"""
db = SessionDB(tmp_path / "state.db")
db.create_session("original-parent", source="test")
db.create_session(
"delegate",
source="delegate",
parent_session_id="original-parent",
model_config={"_delegate_from": "original-parent"},
)
db.end_session("delegate", "compression")
db.create_session(
"delegate-continuation",
source="delegate",
parent_session_id="delegate",
model_config={"_delegate_from": "original-parent"},
)
db.create_session(
"branch",
source="test",
parent_session_id="original-parent",
model_config={"_branched_from": "original-parent"},
)
db.end_session("branch", "compression")
db.create_session(
"branch-continuation",
source="test",
parent_session_id="branch",
model_config={"_branched_from": "original-parent"},
)
delegate_holder = f"pid={os.getpid()}:turn=delegate"
assert db.try_acquire_session_turn_lease(
"delegate", delegate_holder, ttl_seconds=5
)
assert db.append_messages_batch(
"delegate-continuation",
[{"role": "user", "content": "owner on inherited tip"}],
turn_lease_holder=delegate_holder,
) == 1
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"delegate-continuation",
[{"role": "assistant", "content": "impostor"}],
turn_lease_holder=f"pid={os.getpid()}:turn=impostor",
)
branch_holder = f"pid={os.getpid()}:turn=branch"
assert db.try_acquire_session_turn_lease(
"branch", branch_holder, ttl_seconds=5
)
assert db.append_messages_batch(
"branch-continuation",
[{"role": "user", "content": "branch owner on inherited tip"}],
turn_lease_holder=branch_holder,
) == 1
with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"):
db.append_messages_batch(
"branch-continuation",
[{"role": "assistant", "content": "branch impostor"}],
turn_lease_holder=f"pid={os.getpid()}:turn=branch-impostor",
)
assert [m["content"] for m in db.get_messages("delegate-continuation")] == [
"owner on inherited tip"
]
assert [m["content"] for m in db.get_messages("branch-continuation")] == [
"branch owner on inherited tip"
]
db.release_session_turn_lease("delegate-continuation", delegate_holder)
db.release_session_turn_lease("branch-continuation", branch_holder)
+50
View File
@@ -0,0 +1,50 @@
"""Behavioral tests for the state-holder and repair-admission authority."""
import os
import pytest
import hermes_state_holders
@pytest.mark.linux_only
def test_foreign_holder_accepts_same_inode_reached_through_an_alias(
tmp_path, monkeypatch
):
"""Descriptor identity is authoritative even when /proc spells another path."""
db_path = tmp_path / "state.db"
db_path.touch()
alias_path = tmp_path / "namespace-alias" / "state.db"
proc_root = tmp_path / "proc"
for pid in (111, 222):
(proc_root / str(pid) / "fd").mkdir(parents=True)
os.symlink(db_path, proc_root / "222" / "fd" / "3")
monkeypatch.setattr(hermes_state_holders.os, "getpid", lambda: 111)
real_listdir = os.listdir
def _listdir(path):
if isinstance(path, str):
path = path.replace("/proc", str(proc_root))
return real_listdir(path)
monkeypatch.setattr(hermes_state_holders.os, "listdir", _listdir)
def _readlink(path):
if path == "/proc/222/fd/3":
return str(alias_path)
return os.readlink(path.replace("/proc", str(proc_root)))
monkeypatch.setattr(hermes_state_holders.os, "readlink", _readlink)
real_stat = os.stat
def _stat(path, *args, **kwargs):
path = str(path).replace("/proc", str(proc_root))
return real_stat(path, *args, **kwargs)
monkeypatch.setattr(hermes_state_holders.os, "stat", _stat)
assert hermes_state_holders.foreign_state_db_holders(db_path) == [
(222, str(alias_path))
]
@@ -0,0 +1,160 @@
"""Unopenable admission lock files must fail CLOSED (#100368).
`state.db` has two cross-process admission authorities that gate destructive
work on a file several Hermes processes share (gateway service, the Desktop
app's `hermes serve` backend, CLI sessions, the TUI slash worker):
* `hermes_state_common.fts_rebuild_admission` — full structural FTS rebuilds
* `hermes_state._cross_process_repair_lock` — writable_schema surgery / VACUUM
Both document themselves as fail-closed, and both honoured that only for a
*timed-out* acquire. When the lock file could not be `open()`ed at all they
yielded True and proceeded "with in-process serialisation only" — which is no
cross-process authority whatsoever.
That inversion is reachable exactly when it does the most damage. Creating the
lock file needs a directory entry and an inode, so on a full disk `open()`
raises ENOSPC — while a sibling process that opened ITS handle before the disk
filled is still mid-rebuild or mid-surgery. Every process then ran concurrent
destructive work on the same live DB, i.e. the precise interleaving PR #93200
added these locks to prevent. #100368 reports that shape: a disk-full trigger,
then a fresh corruption on every boot with other writers alive, and no
re-corruption on a boot with zero other writers.
These tests drive a real unopenable lock path (a directory where the code
expects a file, so `open()` raises a genuine OSError from the kernel) rather
than monkeypatching the helpers, and assert the deferral is honoured at both
the primitive and the behavior level.
"""
import sqlite3
import sys
from pathlib import Path
import pytest
import hermes_state
import hermes_state_common
from hermes_state import SessionDB, repair_state_db_schema
def _make_unopenable(lock_path: Path) -> None:
"""Make ``open(lock_path, "a+b")`` raise a real OSError.
A directory standing where the code expects a regular file yields
IsADirectoryError on POSIX and PermissionError on Windows — both OSError,
both raised by the kernel. This stands in for the ENOSPC/EMFILE the field
reports hit, without needing to fill a real disk.
"""
lock_path.unlink(missing_ok=True)
lock_path.mkdir(parents=True, exist_ok=True)
with pytest.raises(OSError):
open(lock_path, "a+b").close()
# ── FTS rebuild authority ───────────────────────────────────────────────────
def test_fts_admission_fails_closed_when_lock_file_is_unopenable(tmp_path):
"""The primitive must refuse admission, not fall back to no authority."""
db_path = tmp_path / "state.db"
_make_unopenable(db_path.with_name(db_path.name + ".fts_rebuild.lock"))
with hermes_state_common.fts_rebuild_admission(db_path) as admitted:
assert admitted is False
def test_fts_admission_still_admits_a_pathless_db(tmp_path):
"""Guardrail: an in-memory store has no cross-process surface at all.
The fix must not turn the legitimate no-op case into a permanent deferral.
"""
with hermes_state_common.fts_rebuild_admission(None) as admitted:
assert admitted is True
def test_rebuild_fts_defers_when_lock_file_is_unopenable(tmp_path):
"""Behavior: the rebuild entry point reports no progress and rebuilds nothing."""
db = SessionDB(db_path=tmp_path / "state.db")
if not db._fts_enabled:
db.close()
pytest.skip("FTS5 unavailable in this build")
try:
db.create_session("s1", source="test")
db.append_message("s1", "user", "hello world")
# Sanity: with an openable lock the rebuild really runs, so a 0 below
# is the deferral and not an unrelated no-op.
assert db.rebuild_fts() >= 1
_make_unopenable(
db.db_path.with_name(db.db_path.name + ".fts_rebuild.lock")
)
assert db.rebuild_fts() == 0
finally:
try:
db.close()
except Exception:
pass
# ── Schema-surgery authority ────────────────────────────────────────────────
def _build_healthy_db(db_path: Path) -> None:
db = SessionDB(db_path=db_path)
db.create_session("s1", source="test")
db.append_message("s1", "user", "hello world")
db.close()
def _corrupt_duplicate_fts(db_path: Path) -> None:
"""Inject a duplicate messages_fts row into sqlite_master.
Reproduces 'malformed database schema (messages_fts) - table
messages_fts already exists'.
"""
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) "
"SELECT type, name, tbl_name, rootpage, sql FROM sqlite_master "
"WHERE name='messages_fts'"
)
conn.commit()
conn.close()
def test_repair_lock_fails_closed_when_lock_file_is_unopenable(tmp_path):
"""The primitive must refuse the repair authority."""
db_path = tmp_path / "state.db"
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
with hermes_state._cross_process_repair_lock(db_path) as holding:
assert holding is False
@pytest.mark.skipif(sys.platform == "win32", reason="writable_schema corruption harness")
def test_repair_skips_surgery_when_lock_file_is_unopenable(tmp_path):
"""Behavior: no writable_schema surgery, no forensic backup, DB untouched.
A full disk is the worst possible moment to start an unsynchronised
VACUUM on a live shared DB, and it is exactly when the lock file cannot
be created.
"""
db_path = tmp_path / "state.db"
_build_healthy_db(db_path)
_corrupt_duplicate_fts(db_path)
assert hermes_state._db_opens_cleanly(db_path) is not None
before = db_path.read_bytes()
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
report = repair_state_db_schema(db_path)
assert report["repaired"] is False
assert "repair lock" in (report["error"] or "")
assert report["backup_path"] is None
assert not list(tmp_path.glob("state.db.malformed-backup-*"))
# The damaged image is left byte-identical for the next (authorised) pass.
assert db_path.read_bytes() == before
@@ -0,0 +1,84 @@
"""Regression coverage for WAL restoration during state.db repair (#101064).
Journal-mode restoration used to open a NEW connection after the exclusive
repair guard had released the live database. In WAL mode a writer could still
hold the unlinked old WAL inode while that second connection created a fresh
``state.db-wal`` path — two generations of one store. The restore must run
through the guard connection, before the guard releases.
"""
import sqlite3
import pytest
import hermes_state
from hermes_state import repair_state_db_schema
def _make_db(path):
conn = sqlite3.connect(str(path), isolation_level=None)
conn.execute("CREATE TABLE sessions (name TEXT)")
conn.execute("INSERT INTO sessions VALUES ('seed')")
conn.close()
def test_wal_restoration_reuses_exclusive_repair_connection(tmp_path, monkeypatch):
"""Unit contract: given the guard connection, no reopen happens."""
db_path = tmp_path / "state.db"
conn = sqlite3.connect(db_path, isolation_level=None)
conn.execute("CREATE TABLE marker (value TEXT)")
def fail_if_reopened(_path):
pytest.fail("WAL restoration reopened state.db outside the repair guard")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", fail_if_reopened)
hermes_state._restore_journal_mode_after_repair(db_path, None, conn=conn)
# The mode itself is whatever apply_wal_with_fallback resolves on this
# runtime (WAL, or DELETE on WAL-reset-vulnerable SQLite builds); the
# contract under test is the connection reuse, asserted above.
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() in ("wal", "delete")
conn.close()
def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch):
"""End to end through repair_state_db_schema: every connection the repair
opens is opened while the exclusive guard is still held, and none after."""
db = tmp_path / "state.db"
_make_db(db)
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy")
# The scratch-space pre-flight wants ~10GB headroom; irrelevant here.
monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda path: None)
def fake_strategies(scratch_path, report):
report["repaired"] = True
report["strategy"] = "test_strategy"
return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies)
events: list[str] = []
real_guard = hermes_state._exclusive_repair_db_guard
real_connect = hermes_state._connect_repair_durable
from contextlib import contextmanager
@contextmanager
def tracing_guard(path):
events.append("guard-enter")
with real_guard(path) as pair:
yield pair
events.append("guard-exit")
def tracing_connect(path, *a, **kw):
events.append("connect")
return real_connect(path, *a, **kw)
monkeypatch.setattr(hermes_state, "_exclusive_repair_db_guard", tracing_guard)
monkeypatch.setattr(hermes_state, "_connect_repair_durable", tracing_connect)
report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is True
assert "guard-exit" in events
after_release = events[events.index("guard-exit") + 1 :]
assert "connect" not in after_release, events
+150
View File
@@ -0,0 +1,150 @@
"""Write-lock patience for the shared state.db (#74478).
A shared state.db is legitimately held for multi-second stretches by
sibling Hermes processes (VACUUM after auto-prune, TRUNCATE checkpoint at
close on a large WAL, a long FTS pass from an older still-running
install). The old attempt-counted retry budget (15 x <=150ms jitter)
gave up in ~1-2s of retrying, so:
- ``append_message`` failed -> the conversation loop aborted the turn as
``session_persistence_failed`` ("No reply: ... session storage could
not be written") even though the store was healthy and merely busy;
- ``SessionDB()`` open failed -> the CLI disabled persistence for the
whole run ("Failed to initialize SessionDB ... database is locked").
These tests lock the DB from a second connection for a bounded window and
assert the three contracts: transcript writes ride out long holds, open
rides out long holds, and exhausted patience raises an error that names
the real cause instead of reading like disk damage.
"""
import sqlite3
import threading
import time
import pytest
from hermes_state import SessionDB
def _hold_write_lock(db_path, hold_s, started_evt):
"""Hold the SQLite write lock on *db_path* for *hold_s* seconds."""
conn = sqlite3.connect(str(db_path), timeout=1.0, isolation_level=None)
try:
conn.execute("BEGIN IMMEDIATE")
started_evt.set()
time.sleep(hold_s)
conn.execute("COMMIT")
finally:
conn.close()
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
yield d
d.close()
class TestTranscriptWritePatience:
def test_append_message_survives_multi_second_lock_hold(self, db, tmp_path):
"""A transcript append must ride out a lock held well past the old
~1-2s attempt-counted budget instead of aborting the turn."""
db.create_session("s1", "cli")
started = threading.Event()
# 3s hold: comfortably beyond the old worst-case retry budget,
# comfortably inside _TRANSCRIPT_WRITE_PATIENCE_S.
holder = threading.Thread(
target=_hold_write_lock, args=(db.db_path, 3.0, started)
)
holder.start()
try:
assert started.wait(5.0)
msg_id = db.append_message(
session_id="s1", role="user", content="survived the lock"
)
finally:
holder.join(timeout=10.0)
assert not holder.is_alive()
assert isinstance(msg_id, int)
msgs = db.get_messages("s1")
assert any(m["content"] == "survived the lock" for m in msgs)
def test_transcript_patience_outlasts_routine_patience(self, db):
"""append_message must be given MORE patience than routine writes —
the invariant that lets background writers give up while the
turn-critical append keeps waiting."""
assert db._TRANSCRIPT_WRITE_PATIENCE_S > db._WRITE_PATIENCE_S
# Both budgets must comfortably exceed the old ~2.25s worst case
# (15 attempts x 150ms) that lost races against real maintenance.
assert db._WRITE_PATIENCE_S >= 10.0
assert db._TRANSCRIPT_WRITE_PATIENCE_S >= 30.0
def test_exhausted_patience_names_the_real_cause(self, db, monkeypatch):
"""When patience genuinely runs out, the error must say the lock was
held by another process — not read like disk/permission damage."""
monkeypatch.setattr(SessionDB, "_WRITE_PATIENCE_S", 0.2)
started = threading.Event()
holder = threading.Thread(
target=_hold_write_lock, args=(db.db_path, 2.0, started)
)
holder.start()
try:
assert started.wait(5.0)
with pytest.raises(sqlite3.OperationalError) as excinfo:
db.set_meta("k", "v") # routine write, short patience
finally:
holder.join(timeout=10.0)
assert not holder.is_alive()
text = str(excinfo.value)
assert "another Hermes process" in text
assert "healthy" in text
def test_write_succeeds_immediately_when_uncontended(self, db):
"""Patience must cost nothing when there is no contention."""
db.create_session("s2", "cli")
t0 = time.monotonic()
db.append_message(session_id="s2", role="user", content="fast")
assert time.monotonic() - t0 < 5.0 # loose: no patience-length stall
class TestOpenLockPatience:
def test_open_survives_multi_second_lock_hold(self, tmp_path):
"""SessionDB() open must wait out a sibling's lock hold instead of
disabling persistence for the whole run."""
db_path = tmp_path / "state.db"
# Create + close so the schema exists (open still runs reconcile DDL
# through the same 1s-timeout connection).
SessionDB(db_path=db_path).close()
started = threading.Event()
holder = threading.Thread(
target=_hold_write_lock, args=(db_path, 3.0, started)
)
holder.start()
try:
assert started.wait(5.0)
db = SessionDB(db_path=db_path) # must NOT raise
finally:
holder.join(timeout=10.0)
assert not holder.is_alive()
try:
db.create_session("s-open", "cli")
db.append_message(session_id="s-open", role="user", content="ok")
assert len(db.get_messages("s-open")) == 1
finally:
db.close()
def test_open_propagates_non_lock_errors_immediately(self, tmp_path):
"""A non-lock open failure must not sit in the patience loop."""
# A directory is not openable as a database file — raises an
# OperationalError that is NOT the locked/busy class.
bad_path = tmp_path / "state.db"
bad_path.mkdir()
t0 = time.monotonic()
with pytest.raises(sqlite3.Error):
SessionDB(db_path=bad_path)
# Must fail well before a full patience window (loose bound).
assert time.monotonic() - t0 < 15.0
@@ -0,0 +1,258 @@
"""Cross-thread races on the shared writer connection (2026-08-20 incident).
``SessionDB`` shares ONE writer connection (``check_same_thread=False``)
guarded by ``self._lock``. A handful of read-only methods executed
statements on that same connection object WITHOUT taking the lock
(``get_compression_lock_holder``, ``list_pending_handoffs``,
``get_handoff_state``, and the no-op fast path of
``clear_session_activity_labels``). When one of those SELECTs raced a
turn-boundary ``append_messages_batch`` on the same connection, CPython's
sqlite3 layer raised a bare ``SystemError`` ("<Connection> returned NULL
without setting an exception") — which is NOT a ``sqlite3.Error``, so it
escaped ``_execute_write``'s entire retry net and destroyed the user's
turn as ``session_persistence_failed``.
Two independent layers are asserted here:
1. The unlocked readers now route through ``_read_ctx()`` (per-thread
read-only connections under WAL), so hammering them concurrently with
turn-shaped batch writes must produce ZERO errors on either side.
2. Exactly-once containment: a bare ``SystemError`` inside the transaction
callback is never replayed, while post-commit maintenance failures are
logged without invalidating the already-durable write.
"""
import sqlite3
import threading
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path, monkeypatch):
monkeypatch.setattr(SessionDB, "_WRITE_PATIENCE_S", 2.0)
monkeypatch.setattr(SessionDB, "_WRITE_RETRY_MIN_S", 0.001)
monkeypatch.setattr(SessionDB, "_WRITE_RETRY_MAX_S", 0.005)
d = SessionDB(db_path=tmp_path / "state.db")
yield d
d.close()
class TestConcurrentReadersDoNotRaceTheWriter:
"""Layer 1: the formerly-unlocked readers, hammered against batch writes.
Before the fix this reproduced the production ``SystemError`` within a
few seconds on every run (each reader independently); after routing the
readers through ``_read_ctx()`` the writer and readers touch different
connection objects and the race is structurally gone.
"""
READER_METHODS = (
"get_compression_lock_holder",
"list_pending_handoffs",
"get_handoff_state",
"clear_session_activity_labels",
)
def _run_race(self, db, reader_fn, duration_s=3.0):
sid = db.create_session("race-sess", "test")
errors = []
stop = threading.Event()
def writer():
n = 0
while not stop.is_set():
try:
db.append_messages_batch(sid, [
{"role": "user", "content": "u%d" % n},
{"role": "assistant", "content": "a%d" % n},
{"role": "tool", "content": "t%d" % n,
"tool_name": "x", "tool_call_id": "c%d" % n},
])
n += 1
except Exception as exc: # noqa: BLE001 — the assertion IS the catch
errors.append(("writer", type(exc).__name__, str(exc)))
stop.set()
return
def reader():
while not stop.is_set():
try:
reader_fn(db, sid)
except Exception as exc: # noqa: BLE001
errors.append(("reader", type(exc).__name__, str(exc)))
stop.set()
return
threads = [threading.Thread(target=writer)] + [
threading.Thread(target=reader) for _ in range(3)
]
for t in threads:
t.start()
deadline = time.monotonic() + duration_s
while time.monotonic() < deadline and not stop.is_set():
time.sleep(0.05)
stop.set()
for t in threads:
t.join(timeout=10)
return errors
def test_compression_lock_holder_reads_race_free(self, db):
errors = self._run_race(
db, lambda d, sid: d.get_compression_lock_holder(sid)
)
assert errors == []
def test_pending_handoff_reads_race_free(self, db):
errors = self._run_race(
db, lambda d, sid: (d.list_pending_handoffs(),
d.get_handoff_state(sid))
)
assert errors == []
def test_activity_label_noop_fast_path_race_free(self, db):
errors = self._run_race(
db, lambda d, sid: d.clear_session_activity_labels(sid)
)
assert errors == []
def test_unlocked_writer_conn_use_is_enumerated(self):
"""Sibling-sweep tripwire: no method may touch ``self._conn``
outside ``with self._lock`` (or the __init__/close lifecycle, which
runs before/after any concurrent access is possible).
This is the AST sweep that found the four incident sites, frozen as
a test so a future convenience read can't quietly reintroduce the
class.
"""
import ast
import inspect
import hermes_state as hs
src = inspect.getsource(hs)
tree = ast.parse(src)
ALLOWED_FUNCS = {
# Lifecycle: run before the instance is shared / after readers
# are drained. Not reachable concurrently with writers.
"__init__", "_connect_and_init",
"_connect_and_init_with_lock_patience", "close",
}
def is_lock_with(node):
if isinstance(node, ast.With):
for item in node.items:
ctx = item.context_expr
if (isinstance(ctx, ast.Attribute)
and ctx.attr == "_lock"
and isinstance(ctx.value, ast.Name)
and ctx.value.id == "self"):
return True
return False
violations = []
class Sweep(ast.NodeVisitor):
def __init__(self):
self.lock_depth = 0
self.func_stack = []
@staticmethod
def _is_conn_attr(node):
return (isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr == "_conn")
def _flag(self, node):
fn = self.func_stack[-1] if self.func_stack else "<module>"
if fn not in ALLOWED_FUNCS:
violations.append((node.lineno, fn))
def generic_visit(self, node):
locked = is_lock_with(node)
is_func = isinstance(
node, (ast.FunctionDef, ast.AsyncFunctionDef)
)
if locked:
self.lock_depth += 1
if is_func:
self.func_stack.append(node.name)
if isinstance(node, ast.Call) and self.lock_depth == 0:
# A method call ON the connection (self._conn.execute(...))
func = node.func
if (isinstance(func, ast.Attribute)
and self._is_conn_attr(func.value)):
self._flag(func.value)
# ...or the connection handed to a helper that will
# execute on it (e.g. _collect_delegate_child_ids(self._conn, ...)).
for arg in list(node.args) + [k.value for k in node.keywords]:
if self._is_conn_attr(arg):
self._flag(arg)
super().generic_visit(node)
if locked:
self.lock_depth -= 1
if is_func:
self.func_stack.pop()
Sweep().visit(tree)
assert violations == [], (
"self._conn used outside 'with self._lock' in: %r — route reads "
"through _read_ctx() (or take the lock); an unlocked statement "
"on the shared writer connection races concurrent writers and "
"raises SystemError, destroying the turn as "
"session_persistence_failed" % (violations,)
)
class TestSystemErrorTransactionBoundary:
"""A bare SystemError must never replay an ambiguous write."""
def test_matching_error_inside_callback_is_not_replayed(self, db):
calls = {"n": 0}
def broken(_conn):
calls["n"] += 1
raise SystemError(
"<TrackedConnection object at 0x0> returned NULL "
"without setting an exception"
)
with pytest.raises(SystemError, match="returned NULL"):
db._execute_write(broken)
assert calls["n"] == 1
def test_post_commit_maintenance_error_does_not_replay_message(
self, db, monkeypatch
):
db.create_session("s1", "cli")
before = db._write_count
maintenance_calls = {"n": 0}
def fail_once(*, max_pages):
maintenance_calls["n"] += 1
if maintenance_calls["n"] == 1:
raise SystemError(
"<TrackedConnection object at 0x0> returned NULL "
"without setting an exception"
)
return 0
monkeypatch.setattr(db, "_FTS_MERGE_EVERY_N_WRITES", 1)
monkeypatch.setattr(db, "_merge_fts_incrementally", fail_once)
message_id = db.append_message("s1", "user", "exactly-once")
matching = [
row for row in db.get_messages("s1")
if row["content"] == "exactly-once"
]
assert isinstance(message_id, int)
assert len(matching) == 1
assert db.get_session("s1")["message_count"] == 1
assert db._write_count == before + 1
assert maintenance_calls["n"] == 1