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