fix(state): arm the live-DB guard by process ancestry, not env alone

Production `state.db` files accumulate zero-message "open" gateway session
rows carrying test-fixture identities (`chat-1` / `user-1` / `wx-chat`), with
matching `gateway_routing` scopes pointing at `pytest-of-*` temp directories.

The escape is structural. Hermetic isolation rides entirely on the process
environment: `HERMES_HOME` says *where* to write, `PYTEST_CURRENT_TEST` /
`PYTEST_VERSION` say *whether the guard is armed*. Both travel in the same
carrier, so a child spawned with a rebuilt environment loses them together —
it resolves the developer's real `state.db` *and* silences the only check
that would have stopped it, in one step. The guard is a no-op in precisely
the situation it was written for.

Back the env probe with process ancestry, which survives an env rebuild:

* `_process_looks_like_pytest()` matches a pytest launcher by argv token
  basename, so `/tmp/pytest-of-dev/...` paths in real argv cannot
  false-positive, and an unreadable process is never assumed to be a test.
* `_has_pytest_ancestor()` walks parents via psutil, memoised, and fails
  open when psutil is unavailable — a real `hermes` run pays for at most
  one walk and keeps the previous behaviour if the walk errors.
* `_in_test_context()` checks env first (two dict lookups, covers the
  in-process case) and only then ancestry.

`_STATE_DB_GUARD_BYPASS` is a module global and cannot cross a process
boundary, so ancestry-armed children would have had no way to opt out at
all; `HERMES_STATE_DB_GUARD_BYPASS=1` is the env-carried twin.

Also sweeps the rows already written. Bulk prune/archive cannot reach them:
their shared selector is pinned to `ended_at IS NOT NULL` so a live session
is never picked, which permanently excludes every never-closed row. Adds a
narrower selector — keyed, still open, and with no messages, tokens, tool
calls, API calls, activity or title — behind
`hermes sessions prune --never-active` (default floor 30 days, honours
--dry-run/--yes). Routing entries naming a deleted row go with it, so the
gateway is never left resuming a session id that no longer exists; `pinned`
and `archived` rows are excluded as explicit user intent.

Closes #82770
This commit is contained in:
joaomarcos
2026-08-09 20:42:53 -03:00
committed by Teknium
parent e0c73bfece
commit 174ce8770d
5 changed files with 595 additions and 2 deletions
+10
View File
@@ -12683,6 +12683,16 @@ def main():
action="store_true",
help="Also delete archived sessions (excluded by default)",
)
sessions_prune.add_argument(
"--never-active",
action="store_true",
help=(
"Instead of ended sessions, delete keyed gateway rows that were "
"opened and never used (no messages, tokens, tool calls or title) "
"and are older than AGE (default 30 days). Ordinary prune can "
"never reach these — it only ever selects ended sessions"
),
)
sessions_archive = sessions_subparsers.add_parser(
"archive",
+72
View File
@@ -55,6 +55,72 @@ def _confirm_prompt(prompt: str) -> bool:
return False
#: Default age floor for `hermes sessions prune --never-active`. Deliberately
#: generous: the rows are worthless but harmless, and a young never-active row
#: may simply be a chat that nobody has replied to yet.
_NEVER_ACTIVE_DEFAULT_DAYS = 30.0
def _prune_never_active_keyed(db, args):
"""`hermes sessions prune --never-active` — drop leaked/dead keyed rows.
Targets keyed gateway rows that were opened and never used at all. The
population is dominated by escaped test fixtures (#82770), which the
hermetic-isolation guard can only stop from being *created* — rows already
written to a developer's state.db need a sweep to leave.
"""
from hermes_cli.session_filters import format_epoch, parse_duration_seconds
older_than = getattr(args, "older_than", None)
if older_than is None:
days = _NEVER_ACTIVE_DEFAULT_DAYS
else:
seconds = parse_duration_seconds(str(older_than))
if seconds is None:
print(
f"Error: --older-than '{older_than}' is not a duration. "
"Use a bare number of days or a form like '2d' / '1w'."
)
return
days = seconds / 86400.0
candidates = db.list_never_active_keyed_sessions(older_than_days=days)
if not candidates:
print(f"No never-active keyed sessions older than {days:g} day(s).")
return
shown = candidates if args.dry_run else candidates[:15]
print(
f"{len(candidates)} never-active keyed session(s) older than "
f"{days:g} day(s) — no messages, tokens, tool calls or title:"
)
for s in shown:
print(
f" {s['id']} {format_epoch(s.get('started_at')):<17} "
f"{(s.get('source') or '-'):<10} {s.get('session_key') or '-'}"
)
if len(candidates) > len(shown):
print(f" … {len(candidates) - len(shown)} more")
if args.dry_run:
print("Dry run — nothing deleted.")
return
if not args.yes and not _confirm_prompt(
f"Delete {len(candidates)} session(s)? [y/N] "
):
print("Aborted.")
return
sessions_dir = get_hermes_home() / "sessions"
deleted, routing_deleted = db.prune_never_active_keyed_sessions(
older_than_days=days, sessions_dir=sessions_dir
)
print(
f"Deleted {deleted} never-active session(s) and {routing_deleted} "
"stale routing entr(ies)."
)
def cmd_sessions(args, sessions_parser=None):
import json as _json
@@ -806,6 +872,12 @@ def cmd_sessions(args, sessions_parser=None):
else:
print(f"Session '{args.session_id}' not found.")
elif action == "prune" and getattr(args, "never_active", False):
# Separate branch on purpose: the shared prune/archive selector is
# pinned to `ended_at IS NOT NULL`, so never-closed rows sit outside
# it by construction and cannot be expressed as one more filter.
_prune_never_active_keyed(db, args)
elif action in ("prune", "archive"):
from hermes_cli.session_filters import (
build_prune_filters,
+210 -2
View File
@@ -423,6 +423,12 @@ def _default_db_path() -> Path:
#: ``@pytest.mark.live_system_guard_bypass``; scripts may set it explicitly.
_STATE_DB_GUARD_BYPASS = False
#: Env-carried twin of ``_STATE_DB_GUARD_BYPASS``. A module global cannot
#: cross a process boundary, so a test that deliberately points a *child* at
#: the live DB has no way to opt out once ancestry arms the guard there.
#: Export this in the child's env instead.
_STATE_DB_GUARD_BYPASS_ENV = "HERMES_STATE_DB_GUARD_BYPASS"
#: Additional production roots to refuse (beyond the platform default
#: ``~/.hermes``). The test conftest injects the pre-sandbox production
#: root here so custom-``HERMES_HOME`` deployments are covered too.
@@ -463,6 +469,82 @@ def _running_under_pytest() -> bool:
)
#: Names that identify a pytest launcher in a process command line. Matched
#: against the *basename* of each argv token so ``/tmp/pytest-of-dev/...``
#: paths — which do show up in real argv — cannot false-positive.
_PYTEST_LAUNCHER_NAMES = frozenset(
{"pytest", "py.test", "pytest.exe", "py.test.exe"}
)
#: Memoised ancestry answer. The process tree above us does not change in a
#: way that matters here, and the walk must not cost anything on the hot path.
_PYTEST_ANCESTOR: Optional[bool] = None
def _process_looks_like_pytest(proc: Any) -> bool:
"""True when *proc*'s command line is a pytest invocation.
Covers both ``pytest ...`` (launcher on argv[0]) and ``python -m pytest``
(launcher as a bare ``pytest`` token). A process whose command line we
cannot read is treated as "not pytest": guessing the other way would
refuse production opens for unrelated reasons.
"""
try:
cmdline = proc.cmdline() or []
except Exception:
return False
for arg in cmdline:
try:
name = os.path.basename(str(arg).strip('"').strip("'")).lower()
except Exception:
continue
if name in _PYTEST_LAUNCHER_NAMES:
return True
return False
def _has_pytest_ancestor() -> bool:
"""True when some ancestor process of this one is a pytest run.
``_running_under_pytest`` reads ``PYTEST_*`` env vars, which a child
spawned with a rebuilt environment loses at the same moment it loses the
``HERMES_HOME`` redirect: that child aims at the production DB *and*
disarms the guard in one step (#82770). Ancestry is the one test-context
signal that survives an env rebuild, so it backs the env check up.
Fails open (``False``) when ``psutil`` is unavailable or the walk errors —
that restores the previous env-only behaviour rather than blocking real
user runs on a psutil hiccup.
"""
global _PYTEST_ANCESTOR
if _PYTEST_ANCESTOR is not None:
return _PYTEST_ANCESTOR
found = False
if psutil is not None:
try:
for parent in psutil.Process().parents():
if _process_looks_like_pytest(parent):
found = True
break
except Exception:
found = False
_PYTEST_ANCESTOR = found
return found
def _in_test_context() -> bool:
"""True when this process is a test run, by environment or by ancestry.
Order matters for cost: the env probe is two dict lookups and covers the
common in-process case, so the ancestry walk only runs for processes the
environment claims are ordinary user runs — and its answer is memoised,
so a real ``hermes`` invocation pays for at most one walk.
"""
if _running_under_pytest():
return True
return _has_pytest_ancestor()
def _production_state_roots() -> List[Path]:
roots: List[Path] = []
real_root = _real_platform_state_root()
@@ -501,8 +583,15 @@ def _ensure_test_isolation(db_path: Path) -> None:
Raises ``RuntimeError`` before any connection, mkdir, journal-mode
pragma, or byte probe can touch the live database. No-op outside
pytest and for hermetic (tmp ``HERMES_HOME``) paths.
"pytest context" means environment *or* process ancestry — see
:func:`_in_test_context`. Env alone is not enough: a child spawned with
a rebuilt environment loses ``PYTEST_*`` and ``HERMES_HOME`` together,
which is precisely the state in which it writes to production (#82770).
"""
if _STATE_DB_GUARD_BYPASS or not _running_under_pytest():
if _STATE_DB_GUARD_BYPASS or os.environ.get(_STATE_DB_GUARD_BYPASS_ENV):
return
if not _in_test_context():
return
try:
resolved = Path(db_path).expanduser().resolve()
@@ -517,7 +606,9 @@ def _ensure_test_isolation(db_path: Path) -> None:
"explicit tmp db_path or let the hermetic conftest redirect "
"HERMES_HOME. If this test genuinely needs the live "
"database, mark it with "
"@pytest.mark.live_system_guard_bypass."
"@pytest.mark.live_system_guard_bypass — or, for a spawned "
f"child process, export {_STATE_DB_GUARD_BYPASS_ENV}=1 in "
"its environment."
)
# ---------------------------------------------------------------------------
@@ -4539,6 +4630,123 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
self._execute_write(_do)
def list_never_active_keyed_sessions(
self, *, older_than_days: float
) -> List[Dict[str, Any]]:
"""Keyed gateway rows that were opened and then never used at all.
Selects rows that are keyed (``session_key IS NOT NULL``), still open
(``ended_at IS NULL``) and carry no evidence of a single turn: no
messages, no tokens, no tool or API calls, no recorded activity, no
title. Such a row is indistinguishable from "never happened".
That is exactly the shape of a leaked test fixture (#82770) — and
also of a chat that was routed but never answered. Both are safe to
drop: there is no transcript to lose, and the gateway mints a fresh
session on the next inbound message either way.
``bulk prune``/``archive`` cannot reach these rows: their shared
selector is pinned to ``ended_at IS NOT NULL`` so that a live session
is never picked, which permanently excludes every never-closed row.
Hence a separate, narrower selector rather than another filter flag.
``pinned`` and ``archived`` rows are excluded — both are explicit
user intent to keep the row around.
"""
cutoff = time.time() - (float(older_than_days) * 86400.0)
with self._lock:
rows = self._conn.execute(
"""
SELECT s.id, s.session_key, s.source, s.chat_id,
s.chat_type, s.user_id, s.started_at
FROM sessions s
WHERE s.session_key IS NOT NULL
AND s.ended_at IS NULL
AND s.title IS NULL
AND s.last_activity_at IS NULL
AND COALESCE(s.message_count, 0) = 0
AND COALESCE(s.tool_call_count, 0) = 0
AND COALESCE(s.api_call_count, 0) = 0
AND COALESCE(s.input_tokens, 0) = 0
AND COALESCE(s.output_tokens, 0) = 0
AND COALESCE(s.pinned, 0) = 0
AND COALESCE(s.archived, 0) = 0
AND s.started_at IS NOT NULL
AND s.started_at < ?
AND NOT EXISTS (
SELECT 1 FROM messages m WHERE m.session_id = s.id
)
ORDER BY s.started_at
""",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
def _delete_routing_entries_for_sessions(self, session_ids: Set[str]) -> int:
"""Drop ``gateway_routing`` rows pointing at any of *session_ids*.
Routing entries are keyed by ``(scope, session_key)`` and record their
target session inside ``entry_json``, so there is no way to reach them
by session id in SQL — the match is done in Python over all scopes.
"""
if not session_ids:
return 0
with self._lock:
rows = self._conn.execute(
"SELECT scope, session_key, entry_json FROM gateway_routing"
).fetchall()
doomed: List[Tuple[str, str]] = []
for row in rows:
try:
entry = json.loads(row["entry_json"] or "{}")
except Exception:
continue
if isinstance(entry, dict) and entry.get("session_id") in session_ids:
doomed.append((row["scope"], row["session_key"]))
if not doomed:
return 0
def _do(conn):
conn.executemany(
"DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?",
doomed,
)
self._execute_write(_do)
return len(doomed)
def prune_never_active_keyed_sessions(
self,
*,
older_than_days: float,
sessions_dir: Optional[Path] = None,
) -> Tuple[int, int]:
"""Delete never-active keyed rows and the routing entries naming them.
Returns ``(sessions_deleted, routing_entries_deleted)``.
The routing entries go first: a stale entry that outlived its target
would leave the gateway resuming a session id that no longer exists.
Deleting the pair is what leaving them both would have amounted to
anyway — the target had no transcript to resume.
Deletion goes through :meth:`delete_session` rather than a bulk
``DELETE`` so the delegate cascade, FTS bookkeeping and on-disk
transcript cleanup stay owned by one implementation.
"""
candidates = self.list_never_active_keyed_sessions(
older_than_days=older_than_days
)
if not candidates:
return (0, 0)
ids = {str(row["id"]) for row in candidates}
routing_deleted = self._delete_routing_entries_for_sessions(ids)
deleted = 0
for session_id in ids:
if self.delete_session(session_id, sessions_dir=sessions_dir):
deleted += 1
return (deleted, routing_deleted)
def list_gateway_sessions(
self,
*,
@@ -0,0 +1,154 @@
"""The live-DB guard must survive a scrubbed child environment (#82770).
Forensic background: a read-only sweep of production ``state.db`` files found
hundreds of zero-message "open" gateway session rows carrying test-fixture
identities (``chat-1`` / ``user-1`` / ``wx-chat``), with matching
``gateway_routing`` scopes pointing at ``pytest-of-*`` temp directories.
The escape is structural, not a one-off test bug. Hermetic isolation rides
entirely on the process environment: ``HERMES_HOME`` says *where* to write and
``PYTEST_CURRENT_TEST`` / ``PYTEST_VERSION`` say *whether the guard is armed*.
Both live in the same carrier, so a child spawned with a rebuilt environment
loses them together — it aims at the developer's real ``state.db`` and
silences the only check that would have stopped it, in one step.
Process ancestry is the signal that survives an env rebuild, so these tests
pin that the guard is armed by ancestry when the environment no longer says
"pytest".
These tests drive ``_ensure_test_isolation`` rather than constructing a real
``SessionDB``: if the guard regresses, the assertion must fail *without* the
test itself writing to the developer's live database.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
import hermes_state
REPO_ROOT = Path(__file__).resolve().parents[2]
# Probe run in the child: resolve the REAL platform state root (not a
# hardcoded ~/.hermes — that root is %LOCALAPPDATA%\hermes on Windows) and
# report whether the guard refuses it.
_CHILD_PROBE = """
import sys
sys.path.insert(0, {repo!r})
import hermes_state
root = hermes_state._real_platform_state_root()
if root is None:
print("NO-ROOT")
else:
try:
hermes_state._ensure_test_isolation(root / "state.db")
except RuntimeError:
print("REFUSED")
else:
print("ALLOWED")
"""
def _scrubbed_env(**overrides):
"""The environment a rebuilt-from-scratch child spawn ends up with."""
env = {
k: v
for k, v in os.environ.items()
if not k.startswith("PYTEST_") and k != "HERMES_HOME"
}
env.update(overrides)
return env
def _run_probe(env):
result = subprocess.run(
[sys.executable, "-c", _CHILD_PROBE.format(repo=str(REPO_ROOT))],
env=env,
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
timeout=120,
)
verdict = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
if verdict == "NO-ROOT":
pytest.skip("no real platform state root resolvable on this machine")
assert verdict in ("REFUSED", "ALLOWED"), (
f"probe produced no verdict.\nstdout={result.stdout!r}\n"
f"stderr={result.stderr!r}"
)
return verdict
class TestScrubbedChildEnvironment:
def test_child_without_pytest_env_still_refuses_production_db(self):
"""The #82770 escape: no PYTEST_* and no HERMES_HOME, yet still a test.
This is the exact shape of the leak — the child resolves the real
``state.db`` because ``HERMES_HOME`` is gone, and the env-only guard
sees a "normal user run" because ``PYTEST_*`` is gone with it.
"""
assert _run_probe(_scrubbed_env()) == "REFUSED"
def test_child_inheriting_pytest_env_still_refuses_production_db(self):
"""The pre-existing env path must keep working unchanged."""
env = dict(os.environ)
env.pop("HERMES_HOME", None)
env.setdefault("PYTEST_CURRENT_TEST", "tests/x.py::test_x (call)")
assert _run_probe(env) == "REFUSED"
def test_env_bypass_lets_a_deliberate_child_through(self):
"""A test that genuinely needs the live DB in a child can opt out.
``_STATE_DB_GUARD_BYPASS`` is a module global and cannot cross a
process boundary, so ancestry-armed children need an env-carried
escape hatch or they would have no way to opt out at all.
"""
env = _scrubbed_env(**{hermes_state._STATE_DB_GUARD_BYPASS_ENV: "1"})
assert _run_probe(env) == "ALLOWED"
class TestPytestProcessRecognition:
"""Unit-level checks for the ancestry predicate's matching rules."""
class _FakeProc:
def __init__(self, cmdline):
self._cmdline = cmdline
def cmdline(self):
return self._cmdline
@pytest.mark.parametrize(
"cmdline",
[
["/usr/bin/python", "-m", "pytest", "tests/"],
["/venv/bin/pytest", "-q"],
[r"C:\venv\Scripts\pytest.exe", "-q"],
["/usr/bin/py.test", "tests/"],
],
)
def test_recognises_pytest_invocations(self, cmdline):
assert hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
@pytest.mark.parametrize(
"cmdline",
[
["hermes", "gateway", "start"],
["/usr/bin/python", "-m", "hermes_cli.main", "sessions", "list"],
# A path that merely *contains* "pytest" is not a pytest process:
# tmp paths like /tmp/pytest-of-dev/... show up in real argv.
["hermes", "run", "--file", "/tmp/pytest-of-dev/test0/input.txt"],
],
)
def test_ignores_non_pytest_invocations(self, cmdline):
assert not hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
def test_unreadable_process_is_not_pytest(self):
class _Denied:
def cmdline(self):
raise PermissionError("access denied")
assert not hermes_state._process_looks_like_pytest(_Denied())
@@ -0,0 +1,149 @@
"""Sweeping never-active keyed gateway rows (#82770).
The live-DB guard stops *new* fixture escapes, but it cannot touch rows that
are already in a developer's ``state.db`` — and bulk prune/archive cannot
either: their shared selector is pinned to ``ended_at IS NOT NULL`` so a live
session is never picked, which permanently excludes every never-closed row.
These tests pin the narrow selector that reaches them, and — more importantly
— pin the rows it must refuse to touch.
"""
import json
import time
import pytest
from hermes_state import SessionDB
DAY = 86400.0
@pytest.fixture()
def db(tmp_path):
return SessionDB(db_path=tmp_path / "state.db")
def _insert(db, session_id, *, age_days, **overrides):
"""Insert a keyed gateway row directly, defaulting to the junk shape."""
row = {
"id": session_id,
"source": "telegram",
"user_id": "user-1",
"session_key": f"agent:main:telegram:dm:{session_id}",
"chat_id": "chat-1",
"chat_type": "dm",
"started_at": time.time() - age_days * DAY,
"message_count": 0,
"tool_call_count": 0,
"api_call_count": 0,
"input_tokens": 0,
"output_tokens": 0,
"archived": 0,
"pinned": 0,
}
row.update(overrides)
cols = ", ".join(row)
placeholders = ", ".join("?" for _ in row)
db._conn.execute(
f"INSERT INTO sessions ({cols}) VALUES ({placeholders})", list(row.values())
)
db._conn.commit()
return session_id
class TestSelector:
def test_selects_old_never_active_keyed_row(self, db):
_insert(db, "junk-old", age_days=45)
found = db.list_never_active_keyed_sessions(older_than_days=30)
assert [r["id"] for r in found] == ["junk-old"]
def test_ignores_row_inside_the_age_floor(self, db):
_insert(db, "junk-young", age_days=3)
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
def test_ignores_unkeyed_row(self, db):
_insert(db, "cli-row", age_days=45, session_key=None, source="cli")
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
def test_ignores_ended_row(self, db):
"""Ended rows belong to ordinary prune — this selector must not
double-claim them."""
_insert(db, "ended", age_days=45, ended_at=time.time() - 40 * DAY)
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
@pytest.mark.parametrize(
"field, value",
[
("message_count", 1),
("tool_call_count", 1),
("api_call_count", 1),
("input_tokens", 12),
("output_tokens", 12),
("title", "kept by the user"),
("last_activity_at", 1785354069.0),
("pinned", 1),
("archived", 1),
],
)
def test_any_sign_of_use_or_intent_protects_the_row(self, db, field, value):
_insert(db, "used", age_days=45, **{field: value})
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
def test_row_with_messages_is_protected_even_if_counter_says_zero(self, db):
"""``message_count`` is a denormalised counter — trust the messages."""
_insert(db, "stale-counter", age_days=45)
db._conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, ?, ?, ?)",
("stale-counter", "user", "hello", time.time() - 44 * DAY),
)
db._conn.commit()
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
class TestPrune:
def test_deletes_candidates_and_leaves_everything_else(self, db):
_insert(db, "junk-a", age_days=45)
_insert(db, "junk-b", age_days=60)
_insert(db, "keeper-young", age_days=1)
_insert(db, "keeper-used", age_days=45, message_count=3)
deleted, _ = db.prune_never_active_keyed_sessions(older_than_days=30)
assert deleted == 2
surviving = {
r[0] for r in db._conn.execute("SELECT id FROM sessions").fetchall()
}
assert surviving == {"keeper-young", "keeper-used"}
def test_drops_routing_entries_pointing_at_deleted_rows(self, db):
"""A routing entry that outlived its target would leave the gateway
resuming a session id that no longer exists."""
_insert(db, "junk", age_days=45)
_insert(db, "keeper", age_days=1)
db.save_gateway_routing_entry(
"agent:main:telegram:dm:junk",
json.dumps({"session_id": "junk"}),
scope="/tmp/pytest-of-dev/test0",
)
db.save_gateway_routing_entry(
"agent:main:telegram:dm:keeper",
json.dumps({"session_id": "keeper"}),
scope="/home/dev/project",
)
deleted, routing_deleted = db.prune_never_active_keyed_sessions(
older_than_days=30
)
assert (deleted, routing_deleted) == (1, 1)
remaining = db._conn.execute(
"SELECT session_key FROM gateway_routing"
).fetchall()
assert [r[0] for r in remaining] == ["agent:main:telegram:dm:keeper"]
def test_no_candidates_is_a_no_op(self, db):
_insert(db, "keeper", age_days=1)
assert db.prune_never_active_keyed_sessions(older_than_days=30) == (0, 0)
assert db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 1