8a5a66d0d8
Rebased onto main (~9700 commits): the branch predates the god-file decomposition and the tests/state -> tests/hermes_state move. Fixes for seams that moved: - cron memory contract patches cron.scheduler_delivery._resolve_origin and hermes_state_registry.acquire (where run_job now reads them). - state.db conformance imports _live_writer_holds_db from hermes_state_repair and drives the public create_quick_snapshot. - update receipt: serve runtimes are reconciled in their own unit vocabulary since #100479, so the "full accounting" row names the serve unit instead of borrowing a gateway relaunch. Deleted (change-detectors / source-greps / duplicates / dead code): - source-text scan of hermes_state*.py for "maintenance-shaped" defs and the symbol REGISTRY it fed (renames are not regressions). - per-op exact-outcome table under a live writer -> one invariant: refuse with a lock error or report zero work, DB stays intact. - copy_db_and_verify pins: the symbol is a revert-scheduled plugin-compat pointer, not production code (check_compat_pointers.py). - cron ON-direction tests already pinned by tests/cron/test_scheduler.py, plus a tautology that never called production code. - exact warning-wording asserts in the env deprecation truth table. - same-file duplicates: fixed-point (implied by idempotence), tripwire round-trips, absent-registry, sequential "race" re-enactment.
353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""Property-style regression suite: Bot Mode canonical-chat resolution algebra.
|
|
|
|
Bug class (10 incidents in 2 weeks): canonical "Bot Chat" resolution forking
|
|
or minting duplicates —
|
|
#92040 newest-session preference made the pinned canonical chat unreachable
|
|
#90705 null canonical pointer minted a duplicate empty Bot Chat per click
|
|
#92692 fail-open minting forked fresh chats after a desktop update
|
|
#90005 same-name group recreation reused old member sessions (PR #91082)
|
|
#90732 infinite fork loop (set_session_title silently dropping conflicts)
|
|
PR #92129 name-based resolution replaced session-id pins for good
|
|
|
|
Standing maintainer rule: canonical-chat identity must FAIL CLOSED — never
|
|
mint on ambiguity. Post-#92129 the contract is name-as-identity:
|
|
|
|
One bot = ONE canonical forever-chat = (profile, session titled exactly
|
|
"Bot Chat"). SessionDB title uniqueness (compare-and-swap in
|
|
``_set_session_title``, ValueError on conflict) makes that pair a registry
|
|
of at most one row. Resolution (``session.list {title}`` and
|
|
``profiles.list``'s ``canonical_session``) is a PURE READ: absent registry
|
|
→ empty result / None, and creation is an explicit, separate,
|
|
adopt-before-mint path (desktop plugin.js). No stored session-id pointer
|
|
exists anywhere in the resolution path.
|
|
|
|
What is pinned here is the Python layer the desktop relies on. The desktop
|
|
click/create flow itself (plugin.js), and group ROOM member-session lifecycle
|
|
(#90005's original surface), live in Electron JS and are covered by
|
|
apps/desktop/src/plugins/hermes-bots/canonical-chat-registry.test.ts; here we pin the DB/RPC algebra
|
|
those flows depend on, including a Python re-enactment of the #90005 and
|
|
#92692 shapes at the registry layer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
import tui_gateway.server as srv
|
|
|
|
CANON = "Bot Chat"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures (style shared with tests/tui_gateway/test_profiles_list_canonical_session.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def home(tmp_path, monkeypatch):
|
|
"""Temp HERMES_HOME so the gateway resolvers read a throwaway state.db.
|
|
|
|
``session.list`` reaches the DB through the gateway's shared launch
|
|
handle (``srv._get_db``), which is bound at process launch — so, like
|
|
tests/tui_gateway/test_session_hidden_rpc.py, we point it at the temp
|
|
DB directly. ``profiles.list`` reads each profile dir's state.db itself
|
|
and only needs the env override.
|
|
"""
|
|
h = tmp_path / ".hermes"
|
|
h.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(h))
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
handles = []
|
|
|
|
def _shared_db():
|
|
database = SessionDB(db_path=h / "state.db")
|
|
handles.append(database)
|
|
return database
|
|
|
|
monkeypatch.setattr(srv, "_get_db", _shared_db)
|
|
yield h
|
|
for database in handles:
|
|
try:
|
|
database.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _db(home):
|
|
from hermes_state import SessionDB
|
|
|
|
return SessionDB(db_path=home / "state.db")
|
|
|
|
|
|
def _add_session(db, sid, *, title="", ts=1000, source="cli", hidden=False, text="hi"):
|
|
db.create_session(sid, source)
|
|
db.append_message(sid, "user", text, timestamp=ts)
|
|
if title:
|
|
with db._lock:
|
|
db._conn.execute(
|
|
"UPDATE sessions SET title = ?, title_source = 'user' WHERE id = ?",
|
|
(title, sid),
|
|
)
|
|
if hidden:
|
|
db.set_session_hidden(sid, True)
|
|
|
|
|
|
def _count_sessions(home):
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(home / "state.db")
|
|
try:
|
|
return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _canonical_rows(home):
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(home / "state.db")
|
|
try:
|
|
return [
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT id FROM sessions WHERE title = ?", (CANON,)
|
|
).fetchall()
|
|
]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _resolve(params=None):
|
|
"""The REAL registry resolver the desktop bot row uses: session.list {title}."""
|
|
p = {"title": CANON, "include_hidden": True}
|
|
p.update(params or {})
|
|
envelope = srv._methods["session.list"](1, p)
|
|
return envelope["result"]["sessions"]
|
|
|
|
|
|
def _canonical_via_profiles():
|
|
"""The REAL roster resolver: profiles.list → canonical_session."""
|
|
envelope = srv._methods["profiles.list"](1, {})
|
|
row = next(p for p in envelope["result"]["profiles"] if p["name"] == "default")
|
|
return row["canonical_session"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State builders — every registry state named in the incident history
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _state_empty(home):
|
|
_db(home).close() # create the DB file with zero sessions
|
|
|
|
|
|
def _state_one_canonical(home):
|
|
db = _db(home)
|
|
_add_session(db, "forever", title=CANON, ts=1000, hidden=True,
|
|
text="forever chat content")
|
|
db.close()
|
|
|
|
|
|
def _state_canonical_plus_newer(home):
|
|
"""#92040 shape: newer ordinary sessions must never outrank the registry."""
|
|
db = _db(home)
|
|
_add_session(db, "forever", title=CANON, ts=1000, hidden=True,
|
|
text="forever chat content")
|
|
_add_session(db, "draft1", title="Scratch", ts=5000, text="stray draft")
|
|
_add_session(db, "draft2", title="", ts=9000, text="newest untitled")
|
|
db.close()
|
|
|
|
|
|
def _state_rotated(home):
|
|
"""Identity rotated: old row renamed away, a NEW session id holds the name.
|
|
|
|
Name-as-identity (PR #92129) means the registry follows the NAME, not any
|
|
remembered session id — a stale pointer must never be consulted.
|
|
"""
|
|
db = _db(home)
|
|
_add_session(db, "old-forever", title="Bot Chat (retired)", ts=1000,
|
|
text="old generation")
|
|
_add_session(db, "new-forever", title=CANON, ts=2000, hidden=True,
|
|
text="new generation")
|
|
db.close()
|
|
|
|
|
|
STATES = {
|
|
"no_sessions": (_state_empty, None),
|
|
"one_canonical": (_state_one_canonical, "forever"),
|
|
"canonical_plus_newer": (_state_canonical_plus_newer, "forever"),
|
|
"rotated_identity": (_state_rotated, "new-forever"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 1 — IDEMPOTENCE: resolve twice, same answer, in every state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("state", list(STATES))
|
|
def test_resolution_is_idempotent(home, state):
|
|
"""Resolving the canonical chat twice returns the identical row both times.
|
|
|
|
#90705 / #90732: any resolver that mutates on read (minting, adopting,
|
|
re-pinning) breaks idempotence and forks the forever-chat.
|
|
"""
|
|
build, expected = STATES[state]
|
|
build(home)
|
|
|
|
first = _resolve()
|
|
second = _resolve()
|
|
|
|
assert first == second
|
|
if expected is None:
|
|
assert first == [] # fail closed: absent registry resolves to nothing
|
|
else:
|
|
assert [r["id"] for r in first] == [expected]
|
|
|
|
|
|
@pytest.mark.parametrize("state", list(STATES))
|
|
def test_profiles_list_agrees_with_registry(home, state):
|
|
"""Roster preview identity == click identity (#92040, PR #92129).
|
|
|
|
profiles.list's canonical_session and session.list's title lookup must
|
|
name the same registry row — never the newest session.
|
|
"""
|
|
build, expected = STATES[state]
|
|
build(home)
|
|
|
|
canonical = _canonical_via_profiles()
|
|
if expected is None:
|
|
assert canonical is None
|
|
else:
|
|
assert canonical["id"] == expected
|
|
assert canonical["root_title"] == CANON
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 2 — NEVER-MINT: resolution is a pure read in every state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("state", list(STATES))
|
|
def test_resolution_never_mints(home, state):
|
|
"""Session count is unchanged by any number of resolutions (#90705, #92692).
|
|
|
|
Fail-closed contract pinned from code (methods_session.py title-lookup
|
|
path and methods_profiles._canonical_session_row): when NO canonical row
|
|
exists the resolvers return []/None — they REFUSE rather than create.
|
|
Creation is an explicit separate path (desktop adopt-before-mint), never
|
|
a side effect of lookup.
|
|
"""
|
|
build, _ = STATES[state]
|
|
build(home)
|
|
|
|
before = _count_sessions(home)
|
|
for _ in range(3):
|
|
_resolve()
|
|
_canonical_via_profiles()
|
|
assert _count_sessions(home) == before
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 3 — UNIQUENESS: at most one canonical row per profile, always
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_title_registry_rejects_second_canonical(home):
|
|
"""SessionDB refuses a second "Bot Chat" title (ValueError, not silence).
|
|
|
|
#90732's fork loop started because set_session_title silently dropped
|
|
conflicting titles; the current CAS raises, which is what lets creators
|
|
adopt instead of minting.
|
|
"""
|
|
db = _db(home)
|
|
_add_session(db, "forever", title=CANON, ts=1000)
|
|
db.create_session("pretender", "cli")
|
|
with pytest.raises(ValueError):
|
|
db.set_session_title("pretender", CANON)
|
|
db.close()
|
|
assert _canonical_rows(home) == ["forever"]
|
|
|
|
|
|
def _adopt_or_mint(home, sid, results, idx):
|
|
"""The adopt-before-mint creation algebra plugin.js runs, re-enacted in
|
|
Python against the real SessionDB: lookup → create+claim → on conflict
|
|
re-lookup and ADOPT. This is the #92692 racing-resolvers shape."""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=home / "state.db")
|
|
try:
|
|
row = db.get_session_by_title(CANON)
|
|
if row:
|
|
results[idx] = row["id"]
|
|
return
|
|
db.create_session(sid, "cli")
|
|
try:
|
|
db.set_session_title(sid, CANON)
|
|
results[idx] = sid
|
|
except ValueError:
|
|
# Lost the race: adopt the winner, never fork.
|
|
row = db.get_session_by_title(CANON)
|
|
results[idx] = row["id"] if row else None
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_two_resolvers_racing_cold_profile_yield_one_canonical(home):
|
|
"""#92692: two resolvers racing on a cold profile mint at most ONE chat.
|
|
|
|
The DB-level guarantee pinned: the title-uniqueness CAS serializes the
|
|
claim; the loser gets ValueError and adopts. Post-state: exactly one
|
|
canonical row, and both racers hold the SAME id.
|
|
"""
|
|
_state_empty(home)
|
|
results = {}
|
|
threads = [
|
|
threading.Thread(target=_adopt_or_mint, args=(home, f"racer-{i}", results, i))
|
|
for i in range(2)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
|
|
rows = _canonical_rows(home)
|
|
assert len(rows) == 1
|
|
assert set(results.values()) == {rows[0]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 4 — RECREATION: a recreated same-name identity is FRESH (#90005)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recreated_same_name_identity_gets_fresh_session(home):
|
|
"""Disband + recreate under the same name must NOT resurrect old rows.
|
|
|
|
#90005's surface (group ROOM member sessions) lives desktop-side in
|
|
plugin.js ($groupChats) and is not importable here; what the fix
|
|
(PR #91082) relies on at this layer is that identity follows the CURRENT
|
|
registry row: once the old row's title is released and a new session
|
|
claims the name, resolution returns ONLY the new session — the retired
|
|
row is never adopted again.
|
|
"""
|
|
db = _db(home)
|
|
_add_session(db, "gen1", title=CANON, ts=1000, text="first life")
|
|
db.close()
|
|
assert [r["id"] for r in _resolve()] == ["gen1"]
|
|
|
|
# Disband: release the name (rename away), then recreate under same name.
|
|
db = _db(home)
|
|
db.set_session_title("gen1", "Bot Chat (disbanded)")
|
|
_add_session(db, "gen2", title=CANON, ts=2000, text="second life")
|
|
db.close()
|
|
|
|
resolved = _resolve()
|
|
assert [r["id"] for r in resolved] == ["gen2"]
|
|
assert _canonical_via_profiles()["id"] == "gen2"
|
|
assert len(_canonical_rows(home)) == 1
|