Files
hermes-agent/hermes_state.py
T
Teknium d15c61b5dc refactor(state): split SessionDB into domain mixins and free-function modules; unify SQL boilerplate
hermes_state.py 17,220 -> 6,442 LOC. Behavior-neutral: every moved body is
AST-identical to the original, verified per extraction.

SessionDB core
- _write_sql / _write_rowcount / _read_one / _read_all replace ~120 copies of
  the `def _do(conn): conn.execute(...)` + `_execute_write(_do)` and
  `with self._read_ctx() as conn: row = conn.execute(...).fetchone()` shapes.
- _set_lineage_column replaces four copies of the recursive compression-lineage
  UPDATE (archived / pinned / hidden / last_read_at).
- _read_session_number unifies the three compression counter readers.
- Dead (zero refs repo-wide): restore_rewound, delete_gateway_routing_entries,
  _is_duplicate_replayed_user_message, SessionPortabilityMixin.get_first_assistant_text.

New mixins bound onto SessionDB via the MRO (logger name stays "hermes_state"):
  hermes_state_messages    SessionMessagesMixin       48 methods
  hermes_state_compression SessionCompressionMixin    30
  hermes_state_gateway     SessionGatewayMixin        26
  hermes_state_maintenance SessionMaintenanceMixin    13
  hermes_state_usage       SessionUsageMixin          12
  hermes_state_titles      SessionTitlesMixin         13
  hermes_state_telegram    SessionTelegramTopicsMixin 11
Origin-internal symbols resolve through a lazy `from hermes_state import ...`
inside the few methods that need them (no import cycle).

New free-function modules, every name re-imported into hermes_state so
`hermes_state.<name>` (and test monkeypatches on it) keep working; intra-module
calls to patched helpers go through the lazy origin import:
  hermes_state_repair   repair/backup/preflight (43 defs)
  hermes_state_wal      journal-mode / PRAGMA policy (33 defs)
  hermes_state_dbfile   header probes, zeroed-db quarantine, stats, holders (21 defs)

Existing mixins: search — shared FTS MATCH/LIKE builders, unified rebuild
status/step/finish engines, state_meta helpers; schema — one legacy/v23 FTS init
branch, shared _live_pk_columns, Row/tuple dual access dropped; portability —
shared _PREVIEW_RAW_SUBQUERY_SQL and _rich_row; common — single
stat_db_file_identity (was 3 copies), AUTO_VACUUM_MIN_FREELIST_RATIO.

Docstrings/comments hand-compacted (AST-identical) keeping every invariant,
ordering rule, failure mode and WHY. Schema SQL, migration order and PRAGMAs
untouched. test_repair_path_has_no_bare_connects repointed to hermes_state_repair.
2026-09-02 13:32:13 -07:00

6445 lines
289 KiB
Python

#!/usr/bin/env python3
"""
SQLite State Store for Hermes Agent.
Provides persistent session storage with FTS5 full-text search, replacing
the per-session JSONL file approach. Stores session metadata, full message
history, and model configuration for CLI and gateway sessions.
Key design decisions:
- WAL mode for concurrent readers + one writer (gateway multi-platform)
- FTS5 virtual table for fast text search across all session messages
- Compression-triggered session splitting via parent_session_id chains
- Batch runner and RL trajectories are NOT stored here (separate systems)
- Session source tagging ('cli', 'telegram', 'discord', etc.) for filtering
"""
import asyncio
import atexit
import errno
import hashlib
import json
import logging
import os
import queue
import random
import re
import sqlite3
import sys
import threading
import time
import uuid
import weakref
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from agent.session_activity import ActivityProvenance
from agent.message_sanitization import _sanitize_surrogates
# Intrinsic persistence marker stamped on message dicts that are known-durable
# (#92231). One shared constant with agent.context_compressor (this module
# already imports agent.* at module level, and context_compressor is a
# transitive dependency via hermes_state_common). run_agent keeps its own
# predating copy — hermes_state cannot import run_agent (circular) — guarded
# by test_marker_constant_in_sync.
from agent.context_compressor import ( # noqa: F401 (re-exported; tests import it from here)
_DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY,
)
from hermes_constants import get_hermes_home
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
from hermes_state_common import ( # noqa: F401 (re-exported for back-compat)
AUTO_VACUUM_MIN_FREELIST_RATIO,
_BRANCH_CHILD_SQL,
_COMPRESSION_CHILD_SQL,
_FTS_CJK_TRIGGERS,
_FTS_TRIGGERS,
_LISTABLE_CHILD_SQL,
_PREVIEW_ELIGIBLE_SQL,
_PREVIEW_RAW_SELECT,
_RECOVERABLE_END_REASONS,
_RECOVERABLE_END_REASONS_SQL,
is_automatic_end_reason,
_RESET_END_REASONS,
_RESET_END_REASONS_SQL,
_ephemeral_child_sql,
_legacy_reset_child_sql,
_shape_preview,
_sql_session_last_active,
_sql_session_last_active_by_id,
escape_like as _escape_like,
DEFERRED_INDEX_SQL,
FTS_CJK_STALE_KEY,
FTS_REBUILD_DEFERRAL_KEY,
FTS_SQL,
FTS_STALE_KEY,
FTS_STORAGE_VERSION,
FTS_TRIGRAM_SQL,
LEGACY_FTS_SQL,
LEGACY_FTS_TRIGRAM_SQL,
MAX_FTS5_QUERY_CHARS,
SCHEMA_SQL,
SCHEMA_VERSION,
_PREVIEW_CONTENT_SQL,
_PREVIEW_HEAD_CHARS,
_PREVIEW_MAX_CHARS,
_PREVIEW_SCAFFOLD_WINDOW,
_PREVIEW_SCAFFOLDED_SQL,
_acquire_db_flock,
_clear_lock_holder_record,
_describe_lock_holder,
_read_lock_holder_record,
is_advisory_lock_contention,
stat_db_file_identity as _stat_db_file_identity,
)
from hermes_state_portability import SessionPortabilityMixin
from hermes_state_telegram import SessionTelegramTopicsMixin, _normalize_telegram_topic_profile_name # noqa: F401 (re-exported for back-compat)
from hermes_state_schema import SessionSchemaMixin
from hermes_state_dbfile import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
_HEADER_PROBE_FDS,
_HEADER_PROBE_LOCK,
_HERMES_CMDLINE_MARKERS,
_RETIRED_HEADER_PROBE_FDS,
_canonical_sqlite_path,
_concrete_state_db_holder_pids,
_connect_tracked_db,
_is_inactive_orphan_desktop_holder,
_looks_like_hermes,
_pread_db_header,
_read_proc_cmdline,
_read_sqlite_application_id,
_stat_sqlite_sidecar_identity,
_watched_sqlite_sidecar_paths,
collect_state_db_stats,
count_db_holders,
is_zeroed_state_db,
iter_deleted_sqlite_sidecar_holders,
quarantine_cross_process_lock,
quarantine_zeroed_state_db,
refuse_deleted_wal_generation,
)
from hermes_state_messages import SessionMessagesMixin
from hermes_state_wal import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
WalUnsupportedError,
_SYNCHRONOUS_FULL,
_SYNCHRONOUS_LEVELS,
_SYNCHRONOUS_NAMES,
_WAL_INCOMPAT_MARKERS,
_WAL_SIZE_LIMIT_BYTES,
_apply_delete_for_wal_reset_bug,
_apply_macos_checkpoint_barrier,
_apply_synchronous_pragma,
_apply_wal_size_limit,
_database_has_content,
_delete_overridden_warned_lock,
_delete_overridden_warned_paths,
_enforce_macos_synchronous_full,
_journal_upgrade_warned_lock,
_journal_upgrade_warned_paths,
_log_configured_delete_overridden_once,
_log_journal_mode_upgrade_once,
_log_wal_fallback_once,
_log_wal_reset_bug_once,
_on_disk_journal_mode,
_set_journal_mode_no_wait,
_wal_fallback_warned_lock,
_wal_fallback_warned_paths,
_wal_reset_bug_warned_lock,
_wal_reset_bug_warned_paths,
_wal_reset_repair_hint,
apply_database_pragmas,
apply_wal_with_fallback,
is_sqlite_wal_reset_vulnerable,
resolve_journal_mode,
resolve_synchronous_level,
sqlite_source_id,
)
from hermes_state_repair import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
_DB_SIDECAR_SUFFIXES,
_FINGERPRINT_SAMPLE_BYTES,
_FINGERPRINT_VOLATILE_HEADER_RANGES,
_MAX_MALFORMED_BACKUPS,
_MAX_PERSISTENT_REPAIR_ATTEMPTS,
_REPAIR_BACKUP_FREE_FRACTION,
_REPAIR_BACKUP_MIN_FREE_BYTES,
_REPAIR_LOCK_POLL_SECONDS,
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND,
_backup_content_identity,
_backup_db_file,
_bump_schema_cookie,
_claim_repair_attempt,
_connect_repair_durable,
_copy_database_snapshot,
_cross_process_repair_lock,
_db_fingerprint,
_db_opens_cleanly,
_exclusive_repair_db_guard,
_existing_malformed_backups,
_live_writer_holds_db,
_mask_volatile_header,
_persistent_repair_attempts_exhausted,
_persistent_repair_exhausted_error,
_probe_journal_mode_for_repair,
_prune_malformed_backups,
_read_repair_ledger,
_reapply_durability_barriers,
_record_repair_outcome,
_release_auto_maintenance_lock,
_repair_backup_headroom_bytes,
_repair_failure_consumes_attempt,
_repair_ledger_path,
_repair_scratch_space_error,
_repair_snapshot_timeout_seconds,
_repair_state_db_schema_locked,
_restore_journal_mode_after_repair,
_run_repair_strategies,
_try_acquire_auto_maintenance_lock,
_unlink_db_triple,
apply_durability_barriers,
preflight_db_writability,
repair_state_db_schema,
)
from hermes_state_titles import SessionTitlesMixin
from hermes_state_usage import SessionUsageMixin
from hermes_state_maintenance import SessionMaintenanceMixin
from hermes_state_gateway import SessionGatewayMixin
from hermes_state_compression import SessionCompressionMixin
from hermes_state_search import SessionSearchMixin
try: # Hard dependency, but tolerate scaffold-phase imports before pip install.
import psutil
except ImportError: # pragma: no cover - stripped/scaffold installs only
psutil = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
MAX_SAFE_RESUME_MESSAGES = 20_000
MAX_SAFE_EXPORT_MESSAGES = 20_000
def _configured_transcript_limit(key: str, fallback: int) -> int:
"""Resolve a transcript safety limit from config at call time.
Reads ``sessions.<key>`` from config.yaml lazily (avoiding a circular
import at module load) and falls back to the module constant when the
config subsystem is unavailable (scaffold installs, stripped test
environments). A value of 0 disables the guard entirely. No caching:
``load_config_readonly`` is already mtime-cached, and resolving fresh
keeps tests that monkeypatch config or the module constants working.
"""
try:
from hermes_cli.config import load_config_readonly
sessions_cfg = load_config_readonly().get("sessions") or {}
value = sessions_cfg.get(key)
if value is None:
return fallback
limit = int(value)
return limit if limit >= 0 else fallback
except Exception:
return fallback
def resolved_max_resume_messages() -> int:
"""Config-resolved resume guard limit (0 disables the guard)."""
return _configured_transcript_limit(
"max_resume_messages", MAX_SAFE_RESUME_MESSAGES
)
def resolved_max_export_messages() -> int:
"""Config-resolved in-memory export guard limit (0 disables the guard)."""
return _configured_transcript_limit(
"max_export_messages", MAX_SAFE_EXPORT_MESSAGES
)
class SessionResumeTooLargeError(ValueError):
def __init__(
self,
message_count: int,
limit: int = MAX_SAFE_RESUME_MESSAGES,
scope: str = "across its lineage",
):
self.message_count = message_count
self.limit = limit
super().__init__(
f"session has at least {message_count} active messages {scope}; "
f"safe resume limit is {limit}. Export the session instead, or set "
"sessions.max_resume_messages: 0 in config.yaml to disable the guard."
)
class SessionExportTooLargeError(ValueError):
def __init__(
self,
session_id: str,
message_count: int,
limit: int = MAX_SAFE_EXPORT_MESSAGES,
):
self.session_id = session_id
self.message_count = message_count
self.limit = limit
super().__init__(
f"session '{session_id}' has at least {message_count} active messages; "
f"safe in-memory export limit is {limit}"
)
_COMPRESSION_LOCK_HOLDER_PID_RE = re.compile(r"(?:^|:)pid=(\d+)(?::|$)")
def _system_prompt_hash(system_prompt: str) -> str:
return hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()
def _compression_lock_holder_process_is_dead(holder: str) -> bool:
"""Return True only when a structured lock holder's local PID is gone.
Compression locks are stored in a host-local SQLite database and holder
IDs created by ``conversation_compression`` start with ``pid=<n>``. A
process killed during gateway shutdown cannot release its lease, so waiting
for the full TTL makes every new turn repeatedly attempt compaction. Reclaim
only when the kernel proves that PID no longer exists; legacy/unstructured
holders, same-process holders, permission errors, and any probe doubt
remain protected until normal TTL expiry (conservative: PID reuse must
never steal a live lease, and a wrongly-kept lease self-heals via TTL).
"""
match = _COMPRESSION_LOCK_HOLDER_PID_RE.search(holder or "")
if match is None:
return False
try:
pid = int(match.group(1))
except (TypeError, ValueError):
return False
if pid <= 0:
return False
if pid == os.getpid():
# Same-process holder (e.g. another thread's live lease): never
# self-reclaim — the lease refresher and release path own it.
return False
if psutil is not None:
try:
# psutil is the canonical cross-platform liveness answer
# (CONTRIBUTING.md "Critical rules" #1). pid_exists() reports
# recycled PIDs as alive — conservative, the TTL still applies.
return not psutil.pid_exists(pid)
except Exception:
return False # any doubt → keep the lease until TTL expiry
# Scaffold-phase fallback only (psutil missing), and POSIX-only: stdlib
# os.kill(pid, 0) is NOT a no-op probe on Windows (bpo-14484 — sig=0 maps
# to CTRL_C_EVENT and can kill the target's console group). Without psutil
# a Windows host stays TTL-only; the lease TTL remains the recovery path.
if os.name == "nt":
return False
try:
os.kill(pid, 0) # windows-footgun: ok — nt early-returns just above
except ProcessLookupError:
return True
except (PermissionError, OSError, OverflowError):
return False
return False
def _scrub_surrogates(value: Any) -> Any:
"""Replace lone surrogates when *value* is text; pass anything else through.
sqlite3 encodes bound ``str`` parameters as UTF-8 and raises
``UnicodeEncodeError`` on lone surrogates (U+D800..U+DFFF), so a single
such code point anywhere in a message aborts the whole write. No-op for
well-formed text.
"""
return _sanitize_surrogates(value) if isinstance(value, str) else value
def workspace_key(row: Dict[str, Any]) -> Optional[str]:
"""A session's workspace grouping key: its git repo root when known, else
its cwd.
Branch is deliberately excluded so checking out a new branch doesn't
fragment a workspace's session history. Returns None for cwd-less (unbound)
sessions. Both fields are already recorded on ``sessions`` — this just picks
the coarser identity for grouping/filtering.
"""
root = (row.get("git_repo_root") or "").strip()
if root:
return root
cwd = (row.get("cwd") or "").strip()
return cwd or None
def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
# Sentinel returned by SessionDB._merge_model_config_json when the session row
# doesn't exist and on_missing="skip" — distinguishes "no row" from the legal
# None result ("merged config is empty → store NULL").
_MODEL_CONFIG_ROW_MISSING = object()
# Billing-bucket classes that aren't a routable provider identity on their
# own — used by session_gateway_runtime's billing_provider fallback and by
# tui_gateway.server._stored_session_runtime_overrides. A session that
# persisted only one of these (never ran /model) must fall back to the
# ambient config default rather than restore a bare bucket. Shared here so
# both consumers stay in sync (previously duplicated as a set in
# tui_gateway/server.py).
_BARE_BILLING_PROVIDERS = frozenset({"auto", "custom"})
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
# ``_`` and ``%`` are LIKE wildcards but ordinary characters in a path
# (``my_project``), so an unescaped prefix also matches sibling directories.
# Escape the needle and pair it with ESCAPE; the literal separator
# backslash in the Windows pattern needs escaping for the same reason. The
# ``=`` arm is an exact compare and keeps the raw prefix.
esc = _escape_like(prefix)
return (
"(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')",
[prefix, f"{esc}/%", f"{esc}\\\\%"],
)
def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
"""Match sessions whose ``workspace_key(row)`` equals ``key``.
Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
when its recorded ``git_repo_root`` equals ``key``, or — for rows that
predate per-session git metadata — when its ``cwd`` is at or under
``key`` (so a session started in ``repo/src`` still groups with ``repo``).
Used by ``hermes -c``/``--resume`` to continue the most recent session in
the *current* workspace rather than the global MRU.
"""
prefix = key.rstrip("/\\") or key
cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
return (
f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
[prefix, *cwd_params],
)
def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
"""Delegate-subagent ids to cascade-delete with *parent_ids*.
Only rows carrying the ``_delegate_from`` marker (set at creation, and
backfilled by the v16 migration) — generic untagged children keep the
orphan-don't-delete contract. Walks marker chains recursively so an
orchestrator subagent's own delegate children go too (FK safety).
"""
df = _delegate_from_json()
seeds = {sid for sid in parent_ids if sid}
# Seed the visited set with the parents themselves. A delegation marker
# chain can loop back onto a parent — a cycle, or a parent that is also
# another parent's delegate child when several ids are deleted at once —
# and without this guard that parent would be collected as one of its own
# descendants and cascade-deleted along with all of its messages. Callers
# delete the parents separately, so parents must never appear in the
# returned child set. (#49148)
found: set[str] = set(seeds)
frontier = list(seeds)
while frontier:
ph = ",".join("?" * len(frontier))
cursor = conn.execute(
f"SELECT id FROM sessions WHERE {df} IN ({ph}) "
f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)",
frontier + frontier,
)
frontier = [row["id"] for row in cursor.fetchall() if row["id"] not in found]
found.update(frontier)
# Return only the discovered children — never the parents themselves.
return [sid for sid in found if sid not in seeds]
def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
ids = _collect_delegate_child_ids(conn, parent_ids)
if ids:
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", ids)
# FK safety: orphan any untagged stragglers pointing at a doomed row.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({ph})",
ids,
)
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", ids)
return ids
T = TypeVar("T")
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
# How long SessionDB stops attempting read-only opens after one fails, before
# probing again. Long enough that a genuinely unreadable file isn't retried per
# query; short enough that transient fd pressure doesn't strand the read pool.
_READ_OPEN_RETRY_SECONDS = 60.0
# Transient SQLITE_IOERR retry budget for READ-ONLY opens (#100436). A WAL
# database being actively written (checkpoint, WAL reset/truncate, frame
# flush) can surface "disk I/O error" to a concurrent ``mode=ro`` reader in
# a millisecond-wide transition window: the read-only connection cannot
# perform the WAL recovery a read through a stale or mid-update -shm file
# needs, because recovery requires writing the -shm index, which mode=ro
# refuses. The window closes on its own (the writer finishes the transition),
# so a bounded number of short retries makes the open succeed instead of
# 500-ing the whole /api/sessions poll (or any other read-only opener).
# Deliberately NOT attempted on writable opens: a writer owns the
# transition, so an IOERR there means a real storage/fd problem.
_READ_ONLY_IOERR_RETRY_ATTEMPTS = 3
_READ_ONLY_IOERR_RETRY_BACKOFF_S = 0.05
# Hard ceiling on read-only connections ALIVE at once against one database
# FILE — pooled idle ones and checked-out ones together, summed over every
# SessionDB in this process that points at that file. See _PathReadBudget.
#
# Deliberately one constant for both the pool's maxsize and the permit count,
# because bounding only the pool bounds the wrong thing. A LifoQueue caps how
# many connections are *returned*; it says nothing about how many are *open*.
# With an open-on-miss checkout, N readers arriving on an empty pool all miss,
# all open, and peak at N — the surplus is closed on release, so nothing
# accumulates forever, but EMFILE is a peak-instant condition and the burst
# that empties the pool is exactly the burst that exhausts the fd table.
#
# So a connection holds a permit for its whole lifetime: acquired in
# _get_read_conn() before the open, released in _close_read_conn() after the
# close. Once permits are gone the read path degrades to the locked writer
# connection instead of opening more descriptors — slower under load, which is
# the correct trade against a process-wide wedge the supervisor cannot see.
_READ_POOL_MAX = 8
# Hard ceiling on read-only connections ALIVE at once in this PROCESS, across
# every state.db it has open.
#
# _READ_POOL_MAX bounds one file. A multiplexed gateway serves N profiles from
# one process and each profile has its OWN state.db, so a per-file ceiling
# still lets the descriptor cost grow with the profile count — the same shape
# as the per-instance bug, one level out (#98573).
#
# Three profiles' worth. Past it, readers on the (N+1)th file degrade to their
# writer connection instead of opening descriptors, which is the same trade
# _READ_POOL_MAX makes and for the same reason: a slow read path is
# recoverable, a process-wide EMFILE is not.
_READ_POOL_PROCESS_MAX = 24
# Warn when one process accumulates more than this many SessionDB handles on a
# single file. Not a limit — writer connections cannot be rationed the way read
# connections can — a diagnostic for the duplicate-handle class of bug.
_HANDLES_PER_PATH_WARN = 4
# Descriptors kept in reserve for everything that is NOT this module: httpx
# sockets, terminal subprocess pipes, log files.
#
# The ceilings above bound Hermes's SQLite descriptors, which is only ever part
# of the fd table. The #98573 report is exactly that case: ~20 state.db
# descriptors were not the whole 256, they were the share that pushed httpx and
# terminal pipes over, and the EMFILE surfaced in tools/terminal_tool.py rather
# than here. So the read pool also yields when the PROCESS is close to its
# limit, whatever is consuming it.
_FD_HEADROOM_RESERVE = 64
# The fd count is a directory listing; cache it briefly so a burst of reads
# does not turn one syscall per query. Stale by at most this long, which can
# let through at most the ceiling's worth of opens — already bounded above.
_FD_USAGE_CACHE_SECONDS = 0.25
_process_read_permits = threading.BoundedSemaphore(_READ_POOL_PROCESS_MAX)
# Count of read opens refused because the process was low on descriptors. The
# only externally visible signal that the guard is firing; guarded by
# _read_budgets_lock.
_read_open_denied_fd_headroom = 0
_fd_usage_lock = threading.Lock()
_fd_usage_cache: "tuple[float, Optional[int]]" = (0.0, None)
def _open_fd_count() -> Optional[int]:
"""Descriptors open in THIS process, or None when it cannot be measured.
``/proc/self/fd`` on Linux, ``/dev/fd`` on macOS and the BSDs. Windows has
neither, and no RLIMIT_NOFILE to compare against, so the guard is inert
there — which is correct: the CRT limit is thousands of handles, not 256.
"""
for fd_dir in ("/proc/self/fd", "/dev/fd"):
try:
return len(os.listdir(fd_dir))
except OSError as exc:
if exc.errno in (errno.EMFILE, errno.ENFILE):
# The probe itself could not get a descriptor. That IS the
# answer: there is no headroom.
return -1
continue
return None
def _fd_soft_limit() -> Optional[int]:
"""The process's soft RLIMIT_NOFILE, or None when there is no usable one."""
try:
import resource
except ImportError:
return None
try:
soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
except (OSError, ValueError):
return None
if soft in (resource.RLIM_INFINITY, -1):
return None
return int(soft)
def _fd_headroom_ok() -> bool:
"""Whether the process can spare a descriptor for a new read connection.
Fails OPEN when the platform cannot be measured (Windows, no fd directory,
unlimited RLIMIT_NOFILE): an unmeasurable platform is not a tight one, and
refusing every read there would be a self-inflicted convoy. Fails CLOSED
only on evidence — a measured shortfall, or a probe that could not get a
descriptor of its own.
"""
soft = _fd_soft_limit()
if soft is None:
return True
global _fd_usage_cache
now = time.monotonic()
with _fd_usage_lock:
stamp, cached = _fd_usage_cache
fresh = cached is not None and (now - stamp) < _FD_USAGE_CACHE_SECONDS
if not fresh:
cached = _open_fd_count()
with _fd_usage_lock:
_fd_usage_cache = (now, cached)
if cached is None:
return True
if cached < 0:
return False
return (soft - cached) > _FD_HEADROOM_RESERVE
def _reclaim_idle_read_conn_anywhere() -> bool:
"""Close one idle read connection on ANY path in this process.
The process ceiling is shared across files, so the connection that has to
go to make room may belong to a different database entirely — a profile
that has been quiet for an hour should not hold descriptors the profile
being served right now needs.
"""
with _read_budgets_lock:
budgets = list(_read_budgets.values())
for budget in budgets:
if budget.reclaim_idle():
return True
return False
class _PathReadBudget:
"""The read-connection permits for ONE database file, shared process-wide.
``_READ_POOL_MAX`` used to be enforced by a ``BoundedSemaphore`` owned by
each SessionDB, which bounded the wrong noun: the descriptors are spent on
a *file*, so N SessionDB objects on one state.db each got their own
allowance and peak scaled as ``N x (1 + _READ_POOL_MAX)``. A long-lived
gateway holds at least two (``SessionStore`` and ``GatewayRunner`` open
independent handles per profile path) and the count grows with the profile
count, which is how a healthy process walked into EMFILE — #98573.
Holding the permits here instead makes the ceiling mean what its docstring
always claimed: read connections ALIVE at once against this path.
One consequence has to be handled rather than documented away. A pooled
idle connection keeps its permit, so the first instance to warm up would
otherwise pin all eight and every later instance — a cron job's transient
handle, a second profile's store — would be permanently demoted to the
locked writer connection. That is why a permit miss first reclaims an
IDLE connection from a peer instance on the same path: idle descriptors
are transferable, in-use ones are not.
"""
def __init__(self) -> None:
self.permits = threading.BoundedSemaphore(_READ_POOL_MAX)
self._lock = threading.Lock()
# Weak so a SessionDB that is dropped without close() cannot pin its
# peers' budget object; __del__ still runs close() and returns the
# permits.
self._members: "weakref.WeakSet[SessionDB]" = weakref.WeakSet()
self._duplicate_handles_warned = False
def register(self, db: "SessionDB") -> None:
with self._lock:
self._members.add(db)
handles = len(self._members)
warn = (
handles > _HANDLES_PER_PATH_WARN
and not self._duplicate_handles_warned
)
if warn:
self._duplicate_handles_warned = True
if warn:
# The read connections are capped; the WRITER connection each
# handle holds is not, and cannot be — a SessionDB without one
# cannot write. The only real bound on writers is not opening
# redundant handles in the first place (which is what
# GatewayRunner borrowing SessionStore's handle does, #98573), so
# the next duplicate should be visible before it becomes an
# incident rather than inferred from an lsof after one.
logger.warning(
"%d live SessionDB handles on %s in this process; each holds "
"its own writer connection (read connections are capped at %d "
"for the file). A long-lived process should share one handle "
"per path.",
handles,
db.db_path,
_READ_POOL_MAX,
)
def acquire(self, requester: "SessionDB") -> bool:
"""Take a permit for a new read connection, or refuse.
Three gates, broadest first: the process's descriptor headroom, the
process-wide read ceiling, then this file's ceiling. Refusing means
the caller serves the read from the locked writer connection — slower,
never an error.
"""
if not _fd_headroom_ok():
global _read_open_denied_fd_headroom
with _read_budgets_lock:
_read_open_denied_fd_headroom += 1
return False
if not self._acquire_process_permit():
return False
if self._acquire_path_permit(requester):
return True
_process_read_permits.release()
return False
def release(self) -> None:
"""Return one connection's permits. Pairs with a successful acquire()."""
self.permits.release()
_process_read_permits.release()
def _acquire_process_permit(self) -> bool:
if _process_read_permits.acquire(blocking=False):
return True
if not _reclaim_idle_read_conn_anywhere():
return False
# Another thread may take the freed permit first; that is a legitimate
# loss, and the caller degrades to the writer lock rather than looping.
return _process_read_permits.acquire(blocking=False)
def _acquire_path_permit(self, requester: "SessionDB") -> bool:
if self.permits.acquire(blocking=False):
return True
if not self.reclaim_idle(exclude=requester):
return False
return self.permits.acquire(blocking=False)
def reclaim_idle(self, exclude: "Optional[SessionDB]" = None) -> bool:
"""Close one idle pooled connection held by a member. True if one went.
Closing it runs release(), which returns both the path permit and the
process permit, so this is the single reclaim primitive both ceilings
use.
"""
with self._lock:
members = [db for db in self._members if db is not exclude]
for member in members:
if member._evict_one_idle_read_conn():
return True
return False
# canonical db path -> the permits for that file. Weak values: the budget
# lives exactly as long as some SessionDB on that path holds a strong
# reference to it, so a test that churns tmp_path databases does not grow
# this map for the life of the process.
_read_budgets: "weakref.WeakValueDictionary[str, _PathReadBudget]" = (
weakref.WeakValueDictionary()
)
_read_budgets_lock = threading.Lock()
def _read_budget_key(db_path) -> str:
"""Canonicalise a db path so two spellings share one budget."""
try:
return str(Path(db_path).resolve())
except OSError:
return str(db_path)
def _read_budget_for(db_path) -> _PathReadBudget:
key = _read_budget_key(db_path)
with _read_budgets_lock:
budget = _read_budgets.get(key)
if budget is None:
budget = _PathReadBudget()
_read_budgets[key] = budget
return budget
# Import-time snapshot used by _default_db_path() to detect a deliberately
# re-pointed DEFAULT_DB_PATH (tests monkeypatch the constant directly).
_IMPORT_DEFAULT_DB_PATH = DEFAULT_DB_PATH
def _default_db_path() -> Path:
"""Resolve the default state DB path at call time.
``DEFAULT_DB_PATH`` is computed when this module is first imported, which
freezes the developer's real ``~/.hermes`` even when a test fixture later
redirects ``HERMES_HOME`` — importing this module during collection was
enough to point every default ``SessionDB()`` at the real state.db.
Precedence:
1. A deliberately re-pointed ``DEFAULT_DB_PATH`` (differs from the
import-time snapshot — the established test escape hatch) wins.
2. Otherwise resolve ``get_hermes_home()`` fresh so a runtime
``HERMES_HOME`` redirect takes effect regardless of import order.
"""
if DEFAULT_DB_PATH != _IMPORT_DEFAULT_DB_PATH:
return DEFAULT_DB_PATH
return get_hermes_home() / "state.db"
# ---------------------------------------------------------------------------
# Live-DB test-isolation guard
# ---------------------------------------------------------------------------
# Forensic evidence (Aug 2026, live developer machine): the production
# ~/.hermes/state.db accumulated pytest fixture rows — sessions with
# chat_id='chat-1'/'123'/'wx-chat' and gateway_routing scopes literally under
# /tmp/pytest-of-*/ — and a pytest-spawned process flipped the journal mode
# out from under the WAL-mode gateway writer, destroying committed
# transcripts ("Persisted transcript lagged live cached history ... possible
# FTS write corruption"). The hermetic conftest redirects HERMES_HOME per
# test, but any escape (a session-scoped fixture running before the autouse
# fixture, a subprocess child launched without HERMES_HOME, a stale worktree
# without the re-pin, or a developer shell that exports HERMES_HOME to the
# real home so the conftest session sandbox is skipped) silently fell
# through to the real database.
#
# This guard is the single choke point: EVERY ``SessionDB`` construction
# resolves its path here, so under pytest a resolution that lands on a
# production state.db fails hard instead of corrupting live data. It is
# env-based (``PYTEST_CURRENT_TEST`` / ``PYTEST_VERSION`` are set by pytest
# and inherited by subprocess children), so it also protects children that
# never import the test conftest.
#: Escape hatch for the rare legitimate case (a test that genuinely needs
#: the real DB). The in-tree conftest sets this for tests marked
#: ``@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.
_STATE_DB_GUARD_EXTRA_DENY_ROOTS: Tuple[Path, ...] = ()
def _real_platform_state_root() -> Optional[Path]:
"""Resolve the REAL platform-default Hermes root for the guard.
Deliberately avoids ``Path.home()`` / ``hermes_constants``: tests
routinely monkeypatch ``Path.home`` to a tempdir, and ``hermes_state``
is often imported lazily *while* such a patch is active — resolving
through the patched callable would misidentify the test's own hermetic
home as "production" (false positive) or, worse, miss the real one
(false negative). ``os.path.expanduser`` reads the HOME environment
variable / passwd entry, which the hermetic conftest never rewrites.
"""
try:
if sys.platform == "win32":
base = os.environ.get("LOCALAPPDATA", "").strip()
root = (
Path(base) / "hermes"
if base
else Path(os.path.expanduser("~")) / "AppData" / "Local" / "hermes"
)
else:
root = Path(os.path.expanduser("~")) / ".hermes"
return root.resolve()
except Exception:
return None
#: Env marker exported by the hermetic test conftest at the same moment it
#: redirects ``HERMES_HOME`` to the per-session tmp isolation root. Its
#: value is that isolation root. Unlike ``PYTEST_*`` (owned by pytest, and
#: routinely scrubbed by tests that rebuild a child environment), this marker
#: is OURS: it declares "this process tree is running under Hermes test
#: isolation", and it inherits into subprocess children by default — so a
#: child that received the patched ``HERMES_HOME`` also received the marker,
#: and a child that resolves a production DB while carrying it is, by
#: definition, an isolation escape (#82770).
_TEST_ISOLATION_MARKER_ENV = "HERMES_TEST_ISOLATION"
def _running_under_pytest() -> bool:
"""True when this process (or a parent test process) is a pytest run."""
return bool(
os.environ.get("PYTEST_CURRENT_TEST")
or os.environ.get("PYTEST_VERSION")
or os.environ.get(_TEST_ISOLATION_MARKER_ENV)
)
#: 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:
token = str(arg).strip('"').strip("'")
# Split on both separators on every host: os.path.basename is
# POSIX-only under Linux and would leave a Windows-style path
# intact, making the matcher's answer depend on the platform.
name = token.replace("\\", "/").rsplit("/", 1)[-1].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()
if real_root is not None:
roots.append(real_root)
for extra in _STATE_DB_GUARD_EXTRA_DENY_ROOTS:
try:
roots.append(Path(extra).expanduser().resolve())
except Exception:
continue
return roots
def _is_production_state_db(resolved: Path, root: Path) -> bool:
"""True when *resolved* is a DB file of the real Hermes home *root*.
Matches files directly in the root (``<root>/state.db``) and profile
homes (``<root>/profiles/<name>/state.db``). Deliberately does NOT
match deeper scratch paths (e.g. repo worktrees that happen to live
under ``~/.hermes/hermes-agent/...``) so hermetic tests using unusual
tempdirs cannot false-positive.
"""
if resolved.parent == root:
return True
try:
rel = resolved.relative_to(root)
except ValueError:
return False
parts = rel.parts
return len(parts) == 3 and parts[0] == "profiles"
def _ensure_test_isolation(db_path: Path) -> None:
"""Fail hard when a pytest-context process resolves a production DB.
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 os.environ.get(_STATE_DB_GUARD_BYPASS_ENV):
return
if not _in_test_context():
return
try:
resolved = Path(db_path).expanduser().resolve()
except Exception:
return
for root in _production_state_roots():
if _is_production_state_db(resolved, root):
raise RuntimeError(
"live-system guard: test attempted to open production "
f"state.db at {resolved} (under real Hermes root {root}). "
"Tests must run against a temporary HERMES_HOME — pass an "
"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 — or, for a spawned "
f"child process, export {_STATE_DB_GUARD_BYPASS_ENV}=1 in "
"its environment."
)
# Last SessionDB() init error, per-process. Surfaced in /resume and
# related slash-command error strings so users know WHY the DB is
# unavailable instead of getting a bare "Session database not available."
# Only SessionDB.__init__ writes to this; kanban_db.connect() failures
# do not update it (by design — kanban failures are reported via their
# own caller's error handling, not via /resume-style slash commands).
_last_init_error: Optional[str] = None
_last_init_error_lock = threading.Lock()
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
Thread-safe via _last_init_error_lock. Callers pass a message to
record a failure or None to clear. SessionDB.__init__ only calls
this to SET on failure — it deliberately does NOT clear on success,
because in a multi-threaded caller (e.g. gateway / web_server per-
request SessionDB() instantiation), a concurrent successful open
racing past a different thread's failure would erase the cause
string that thread's /resume handler is about to format. Explicit
clears (e.g. test fixtures) are still supported by passing None.
"""
global _last_init_error
with _last_init_error_lock:
_last_init_error = msg
def get_last_init_error() -> Optional[str]:
"""Return the most recent state.db init failure, if any.
Slash-command handlers (``/resume``, ``/title``, ``/history``, ``/branch``)
call this to surface the underlying cause in their error messages when
``_session_db is None``. Returns ``None`` if SessionDB initialized
successfully (or hasn't been attempted).
"""
return _last_init_error
# Distinctive opening shared by both background-review harness prompts
# (_SKILL_REVIEW_PROMPT and _MEMORY_REVIEW_PROMPT in agent/background_review.py).
# Matched case-sensitively against the leading content of a user/system message.
_REVIEW_HARNESS_PREFIXES = (
"Review the conversation above and update the skill library",
"Review the conversation above and consider saving to memory",
)
def _is_background_review_harness_message(msg: Dict[str, Any]) -> bool:
"""True when ``msg`` is a persisted background-review harness prompt.
These are user/system turns the forked skill/memory review agent wrote into
a real session in older builds (before the ``_persist_disabled`` isolation
fix). They instruct the agent to act as the curator under a hard tool
restriction, so replaying them as live history hijacks the session.
"""
if not isinstance(msg, dict):
return False
if msg.get("role") not in {"user", "system"}:
return False
content = msg.get("content")
if not isinstance(content, str):
return False
head = content.lstrip()
return any(head.startswith(p) for p in _REVIEW_HARNESS_PREFIXES)
def _strip_background_review_harness(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Drop background-review harness messages and the curator-mode assistant
reply that immediately followed each one.
Walk the list once; when a harness user/system message is found, skip it and
also skip the next message if it is the assistant turn that answered it.
Everything else passes through untouched and in order.
"""
if not messages:
return messages
out: List[Dict[str, Any]] = []
skip_next_assistant = False
for msg in messages:
if _is_background_review_harness_message(msg):
skip_next_assistant = True
continue
if skip_next_assistant:
skip_next_assistant = False
if isinstance(msg, dict) and msg.get("role") == "assistant":
# The curator-mode reply to the harness prompt — drop it.
continue
out.append(msg)
return out
# Matches a bare protocol/tool-name marker such as "[memory]" or "[skill_manage]".
_STALE_TOOL_CALL_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
def _is_stale_tool_call_marker_message(msg: Dict[str, Any]) -> bool:
"""True when ``msg`` is a persisted assistant turn whose content is a bare
bracketed marker (e.g. ``[memory]``) left over from a tool-call turn.
Before the #78148 fix in ``agent.conversation_loop``, a local tool-call
template could emit a bare marker as assistant content alongside a real
tool call. The loop cached that marker as a fallback and later replayed
it as the "final response", persisting it into the session. Sessions
written before the fix can still carry these rows.
"""
if not isinstance(msg, dict):
return False
if msg.get("role") != "assistant":
return False
if not msg.get("tool_calls"):
return False
content = msg.get("content")
if not isinstance(content, str):
return False
return bool(_STALE_TOOL_CALL_MARKER_RE.fullmatch(content.strip()))
def _strip_stale_tool_call_markers(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Clear bare protocol-marker content persisted before the #78148 fix.
Replaying "[memory]" as if the model had actually answered teaches the
model, by example, to keep emitting the same marker in later turns — the
exact symptom the issue reported. Only the stray ``content`` field is
blanked; the tool call and its result are left untouched so provider
tool_call/tool_result pairing stays intact. Sessions with no affected
rows pass through unchanged.
"""
repaired = 0
for msg in messages:
if _is_stale_tool_call_marker_message(msg):
msg["content"] = ""
repaired += 1
if repaired:
logger.info(
"Cleared %d stale tool-call marker message(s) while restoring session (#78148)",
repaired,
)
return messages
def format_session_db_unavailable(prefix: str = "Session database not available") -> str:
"""Format a user-facing 'session DB unavailable' message with cause.
When ``SessionDB()`` init fails, callers set ``_session_db = None`` and
several slash commands (/resume, /title, /history, /branch) previously
responded with a bare ``"Session database not available."`` — no
indication of WHY. This helper includes the captured cause (typically
``"locking protocol"`` from NFS/SMB) and points users at the known
culprit so they can fix it themselves.
Example output:
Session database not available: locking protocol (state.db may be
on NFS/SMB — see https://www.sqlite.org/wal.html).
"""
cause = get_last_init_error()
if not cause:
return f"{prefix}."
hint = ""
if any(marker in cause.lower() for marker in _WAL_INCOMPAT_MARKERS):
hint = " (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html)"
return f"{prefix}: {cause}{hint}."
# ---------------------------------------------------------------------------
# Malformed-schema recovery
# ---------------------------------------------------------------------------
# A distinct, nastier failure class than a malformed FTS *inverted index*:
# the ``sqlite_master`` schema table itself becomes inconsistent — most
# commonly a DUPLICATE object definition, e.g. two ``CREATE VIRTUAL TABLE
# messages_fts`` rows. SQLite parses the entire schema while preparing the
# FIRST statement on a connection, so on this class *every* statement raises
# before it runs — including ``PRAGMA journal_mode`` (which is why this trips
# in ``apply_wal_with_fallback`` during ``SessionDB.__init__``, long before
# ``_init_schema`` is reached) and even ``PRAGMA integrity_check`` and a plain
# ``DROP TABLE``. The only operations that still work are
# ``PRAGMA writable_schema=ON`` plus direct ``sqlite_master`` surgery.
#
# Symptom users hit (Desktop/Dashboard show "no sessions" while 200+ JSON
# files sit on disk):
# sqlite3.DatabaseError: malformed database schema (messages_fts) -
# table messages_fts already exists
#
# The canonical ``sessions`` / ``messages`` data is intact in these cases —
# only the derived schema is broken — so recovery preserves all transcripts
# and merely rebuilds the FTS layer.
_MALFORMED_SCHEMA_MARKERS = ("malformed database schema",)
_MALFORMED_DB_MARKERS = (
*_MALFORMED_SCHEMA_MARKERS,
"database disk image is malformed",
)
# Process-global guard so auto-repair is attempted at most once per DB path
# per process (prevents repair loops and serialises concurrent web_server /
# gateway opens against the same malformed file).
_repair_attempted_paths: set[str] = set()
_repair_attempt_lock = threading.Lock()
def is_malformed_db_error(exc: BaseException) -> bool:
"""True for explicit malformed-schema or generic corrupt-image errors.
This broad classifier is for diagnostics and explicit offline recovery
dispatch. Runtime repair must use :func:`is_malformed_schema_error`, since
a generic corrupt-image error does not identify the damaged object.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
return any(marker in str(exc).lower() for marker in _MALFORMED_DB_MARKERS)
# SQLITE_IOERR, matched as a plain substring so wrapped error strings still
# classify. Shared by the read-only open retry and the write-path BEGIN retry.
_DISK_IO_ERROR_MARKER = "disk i/o error"
# Broader set for HTTP classification: a read that failed for one of these
# reasons found the store BUSY, not gone. Callers map it to 503 (retry, the
# list was not cleared) instead of 500. Corruption is deliberately absent —
# a malformed store must surface, not be retried into a timeout.
_TRANSIENT_SQLITE_MARKERS = (
_DISK_IO_ERROR_MARKER,
"database is locked",
"database table is locked",
"busy",
)
def is_transient_sqlite_error(exc: BaseException) -> bool:
"""True when a SQLite failure means "busy right now", not "damaged".
One predicate so the read paths cannot drift apart on what counts as
recoverable: the read-only open retry, and the HTTP 503-vs-500 split on
the session-list endpoints, classify the same way.
"""
if not isinstance(exc, sqlite3.OperationalError):
return False
message = str(exc).lower()
return any(marker in message for marker in _TRANSIENT_SQLITE_MARKERS)
def _is_transient_read_only_ioerr(exc: sqlite3.OperationalError, *, attempt: int) -> bool:
"""True when a read-only open should be retried rather than raised.
A ``mode=ro`` connection cannot perform WAL recovery (recovery needs to
write the -shm index, which read-only mode refuses), so a concurrent WAL
checkpoint / reset / frame-flush can surface ``SQLITE_IOERR`` ("disk I/O
error") to a reader on an otherwise healthy database (#100436). The
transition is millisecond-scale, so a bounded number of short retries
clears it without changing classification for genuine storage failures —
a persistent IOERR still exhausts the budget and propagates.
"""
return (
attempt < _READ_ONLY_IOERR_RETRY_ATTEMPTS
and _DISK_IO_ERROR_MARKER in str(exc).lower()
)
def is_malformed_schema_error(exc: BaseException) -> bool:
"""True only when SQLite explicitly reports malformed schema text.
A generic ``database disk image is malformed`` error is SQLITE_CORRUPT
and may come from any B-tree or freelist page. It does not prove that
canonical rows are intact, so runtime schema/FTS repair must fail closed.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
return any(marker in str(exc).lower() for marker in _MALFORMED_SCHEMA_MARKERS)
# Markers that mean the host filesystem cannot accept another write. Kept as
# plain substrings so OSError, sqlite3.OperationalError, and wrapped RPC
# error strings all match the same helper.
_DISK_FULL_MARKERS = (
"no space left on device",
"not enough space",
"database or disk is full", # SQLITE_FULL
"disk full",
"full disk",
"enospc",
)
def is_disk_full_error(exc: BaseException | str | None) -> bool:
"""True when *exc* (or a stringified error) is a disk-full / ENOSPC failure.
Covers:
* ``OSError`` with ``errno.ENOSPC``
* SQLite ``OperationalError: database or disk is full`` (SQLITE_FULL)
* Plain English / errno strings that survive RPC wrapping
"""
if exc is None:
return False
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOSPC:
return True
text = exc if isinstance(exc, str) else str(exc)
lowered = text.lower()
return any(marker in lowered for marker in _DISK_FULL_MARKERS)
# Every cause bucket classify_persistence_error can return. Consumers that
# enumerate causes (e.g. the cron scheduler's explainer-variant suppression)
# must iterate this tuple instead of hardcoding the list, so adding a bucket
# can never silently desynchronize them.
PERSISTENCE_ERROR_CAUSES = (
"locked",
"compression",
"compression_closed",
"turn_lease",
"corrupt",
"replaced",
"disk",
"unknown",
)
# Markers that mean the database FILE itself is structurally damaged. Kept
# as plain substrings so sqlite3.DatabaseError, wrapped RPC strings, and
# logged message text all match the same helper. NOTE: "database disk image
# is malformed" contains the word "disk", so this check MUST run before the
# disk-full/readonly bucket in classify_persistence_error — otherwise real
# B-tree corruption gets reported to the user as "free some disk space"
# (the misdiagnosis documented on #77386).
_DB_CORRUPTION_MARKERS = (
"malformed", # "database disk image is malformed" (SQLITE_CORRUPT)
"file is not a database", # SQLITE_NOTADB (also connection-level poisoning)
"not a database",
"database corruption",
)
def classify_persistence_error(exc_or_str) -> str:
"""Classify a session-persistence failure into a coarse cause bucket.
Fast-failing a turn on a SessionDB write error is deliberate (the
transcript would otherwise be lost on restart), but the *guidance* the
user gets must match the cause: sustained SQLite write-lock contention
("database is locked" on a shared state.db) needs "storage was busy,
send it again", while a full disk or read-only database needs the
disk-space/permissions advice. Returns one of PERSISTENCE_ERROR_CAUSES:
* ``"locked"`` — SQLite lock/busy contention (another process holds the
database write lock); transient, retry-later guidance applies.
* ``"compression"`` — a live compression lease refused the transcript
write; the database itself is healthy and unlocked.
* ``"compression_closed"`` — the write targeted a session already
rotated (closed) by compression and no live continuation was adopted;
the store is healthy — the client must refresh/adopt the new session
id, so disk-space advice would be a misdiagnosis.
* ``"turn_lease"`` — a presented session-turn-lease holder no longer
owns the conversation (expired, released, or reclaimed); fail-fast
fencing, not a storage fault.
* ``"corrupt"`` — the database file itself is structurally damaged
(``database disk image is malformed`` / SQLITE_NOTADB). Distinct from
``"disk"``: freeing space cannot help, the user needs the repair path
(``hermes doctor`` / automatic schema surgery).
* ``"replaced"`` — the ``state.db`` path no longer names the file this
process opened (out-of-band ``cp``/``mv``/restore). In-file FTS repair
cannot help; writes to the live handle must stop.
* ``"disk"`` — disk full / read-only / permission-shaped failures
(delegates the disk-full patterns to :func:`is_disk_full_error` so the
two classifiers can never drift apart — e.g. ENOSPC).
* ``"unknown"`` — anything else (or no visible exception at all).
"""
if exc_or_str is None:
return "unknown"
# A refused write during a live compression lease is contention, not
# storage damage — but its message ("is being compressed by another
# writer" / "Compression lease lost") contains neither "locked" nor
# "busy", so it must be matched by type and by phrase (for strings that
# survived RPC wrapping).
if isinstance(exc_or_str, SessionTurnLeaseLostError):
return "turn_lease"
if isinstance(exc_or_str, CompressionSessionClosedError):
return "compression_closed"
if isinstance(exc_or_str, CompressionSessionBusyError):
return "compression"
if isinstance(exc_or_str, StateDbReplacedError):
# Includes DeletedWalGenerationError (subclass).
return "replaced"
if isinstance(exc_or_str, StateDbCorruptError):
return "corrupt"
text = str(exc_or_str).lower()
if "turn lease" in text:
return "turn_lease"
if "closed by compression" in text:
return "compression_closed"
if "being compressed" in text or "compression lease" in text:
return "compression"
if "was replaced underneath" in text:
return "replaced"
if "deleted state.db-wal" in text or "deleted state.db-shm" in text:
return "replaced"
# Structural corruption BEFORE the lock and disk buckets: "database disk
# image is malformed" contains "disk" (and some wrapped corruption
# strings mention "locked" recovery attempts), so later buckets would
# steal it and misdiagnose damage as space/contention.
if any(marker in text for marker in _DB_CORRUPTION_MARKERS):
return "corrupt"
if (
"locked" in text
or "busy" in text
):
return "locked"
if (
is_disk_full_error(exc_or_str)
or "disk" in text
or "readonly" in text
or "read-only" in text
):
return "disk"
return "unknown"
# Cross-process serialisation for the schema-surgery paths below. The
# ``_repair_attempt_lock`` above is a ``threading.Lock`` — it only covers
# threads inside ONE interpreter, yet a normal Hermes host runs several
# independent processes against the same ``state.db``: the gateway service,
# the Desktop app's own ``hermes serve`` backend, interactive CLI sessions,
# and the TUI slash worker. Two of those hitting a malformed DB at once each
# ran the full ``writable_schema`` surgery + ``VACUUM`` on their own private
# connection, with nothing serialising them.
#
# The timeout is sized for the slowest legitimate holder — a ``VACUUM`` over a
# multi-GB DB in strategy 2. Waiting that long is not a new stall: before this
# lock the losing caller spent the same minutes running its own surgery, it
# just did so on top of the winner's.
_REPAIR_LOCK_TIMEOUT_SECONDS = 120.0
_IS_WINDOWS = sys.platform == "win32"
# ── Repair-loop bounding + dead-backup hygiene (#86747) ─────────────────────
#
# ``_claim_repair_attempt`` above is an in-memory set: it bounds the loop
# only WITHIN one process. A corruption class the strategies cannot heal
# (b-tree page damage) failed repair on EVERY process start, and each pass
# took a fresh ~900MB forensic backup — 105 attempts / 89GB of identical
# dead copies in the reporting install. Two persistent bounds fix the class:
#
# * a sidecar attempt ledger (``<db>.repair-attempts.json``) that refuses
# further surgery after ``_MAX_PERSISTENT_REPAIR_ATTEMPTS`` failures on
# the SAME damaged file (fingerprint = size + a bounded content sample; any
# successful repair or replacement changes it and resets the count);
# * backup dedupe + a retention cap in ``_backup_db_file`` — an identical
# damaged file is never copied twice, and only the newest
# ``_MAX_MALFORMED_BACKUPS`` forensic copies are kept.
# ── CJK-bigram FTS index (replaces the trigram index when available) ────
#
# The trigram tokenizer needs >=3 chars per query term, so 1-2 char CJK
# terms (ubiquitous in Korean/Chinese: 일본, 구글, 项目, ...) fall through
# to a LIKE full-table scan — measured 3-6s CPU per query on multi-GB
# installs and the dominant base cost of session_search on CJK workloads.
#
# ``cjk_unicode61`` (native/fts5_cjk/, a ~250-line loadable FTS5 tokenizer
# with no dependencies) wraps unicode61: maximal CJK runs are re-emitted as
# overlapping character bigrams (Lucene CJKAnalyzer semantics), everything
# else passes through unchanged. FTS5 phrase semantics turn a query term's
# consecutive bigrams into exact substring matching down to 2 chars at
# index speed. Contributed by Soju06 (PR #65544).
#
# Same v23 storage discipline as the trigram table it replaces:
# external-content over a tool-row-excluding view (zero inline text
# copies; tool rows stay searchable via ``messages_fts``), triggers gated
# on a DEDICATED marker pair (``fts_cjk_rebuild_high_water`` /
# ``fts_cjk_rebuild_progress``) so a cjk-only backfill — e.g. the
# trigram→cjk upgrade on an already-optimized DB — never gates the
# complete ``messages_fts`` index's triggers.
#
# The table exists ONLY when the loadable tokenizer is available
# (``~/.hermes/lib/libfts5_cjk.so``, built by ``native/fts5_cjk/build.sh``).
# A process that cannot load it self-heals by dropping the cjk triggers
# (message writes keep working; the index goes stale and is rebuilt by the
# next ``hermes sessions optimize-storage`` on a capable host).
#
# Split DDL: the table/view part is safe to ensure any time; the triggers
# are created ONLY while the index is complete-or-marker-gated. A stale
# index (trigger gap of unknown extent) must keep its triggers DROPPED —
# an external-content 'delete' op for a rowid the index never held is the
# canonical FTS5 index-corruption hazard the v23 marker gating exists to
# prevent.
FTS_CJK_TABLE_SQL = """
CREATE VIEW IF NOT EXISTS messages_fts_cjk_src AS
SELECT id, role, content, tool_name, tool_calls
FROM messages
WHERE role <> 'tool';
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_cjk USING fts5(
content,
tool_name,
tool_calls,
content='messages_fts_cjk_src',
content_rowid='id',
tokenize='cjk_unicode61'
);
"""
FTS_CJK_TRIGGER_SQL = """
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_delete AFTER DELETE ON messages
WHEN old.role <> 'tool'
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_update
AFTER UPDATE OF content, tool_name, tool_calls, role ON messages
WHEN (old.content IS NOT new.content
OR old.tool_name IS NOT new.tool_name
OR old.tool_calls IS NOT new.tool_calls
OR old.role IS NOT new.role)
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
SELECT 'delete', old.id, old.content, old.tool_name, old.tool_calls
WHERE old.role <> 'tool';
INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
SELECT new.id, new.content, new.tool_name, new.tool_calls
WHERE new.role <> 'tool';
END;
"""
def fts5_cjk_so_path() -> Path:
"""Location of the cjk_unicode61 loadable extension."""
env = os.getenv("HERMES_FTS5_CJK_SO")
if env:
return Path(env).expanduser()
return get_hermes_home() / "lib" / "libfts5_cjk.so"
def _cjk_fts_config_enabled() -> bool:
"""config.yaml ``sessions.cjk_fts`` (default on), via its env bridge."""
return os.getenv("HERMES_CJK_FTS", "1").strip().lower() not in (
"0", "false", "off", "no",
)
def load_fts5_cjk_extension(conn: sqlite3.Connection) -> bool:
"""Best-effort load of the cjk_unicode61 tokenizer into ``conn``.
Returns False (never raises) when the .so is absent, the feature is
disabled via ``sessions.cjk_fts``, or this Python build has extension
loading compiled out — every caller treats False as "behave exactly as
before the cjk index existed".
"""
if not _cjk_fts_config_enabled():
return False
path = fts5_cjk_so_path()
if not path.exists():
return False
try:
conn.enable_load_extension(True)
try:
conn.load_extension(str(path))
finally:
conn.enable_load_extension(False)
return True
except Exception:
logger.warning("fts5_cjk extension load failed (%s)", path, exc_info=True)
return False
class CompressionSessionClosedError(RuntimeError):
"""A durable write targeted a parent already closed by compression."""
def __init__(self, session_id: str):
self.session_id = session_id
super().__init__(
f"Session {session_id!r} is closed by compression; "
"adopt its live continuation before appending messages"
)
class CompressionSessionBusyError(RuntimeError):
"""A non-owner tried to write while compression owns the session."""
class SessionCompressionInProgressError(CompressionSessionBusyError):
"""A concurrent writer collided with a *live* compression lock.
Split out from :class:`CompressionSessionBusyError` because the two
conditions that class covers need opposite handling. This one is
transient: a healthy compressor holds the session for a few seconds and
the lock row carries its own ``expires_at``, so the write can simply wait
(see ``_execute_write``'s patience loop). The other case, a compressor
discovering its own lease is gone, is permanent and must fail fast rather
than spin out the whole patience budget.
Subclassing keeps every existing ``except CompressionSessionBusyError``
handler working unchanged.
"""
class SessionTurnLeaseLostError(RuntimeError):
"""A transcript write presented a turn-lease holder that no longer owns it.
Fail-fast fencing: do not retry inside ``_execute_write``. The caller
either still thinks it owns the conversation after expiry/reclaim, or
the lease row is gone. A later writer may already be persisting a
newer turn; landing this write would interleave a stale reply.
"""
class StateDbReplacedError(RuntimeError):
"""The state.db path no longer names the file this SessionDB opened.
Raised when an out-of-band ``cp``/``mv``/restore replaces the database
under a live gateway. In-place FTS repair and fail-open trigger
dropping cannot fix a generation mismatch; they amplify it.
"""
class DeletedWalGenerationError(StateDbReplacedError):
"""A live process holds a deleted state.db-wal / -shm generation.
Opening or writing through this handle would mint a second WAL inode
(or keep committing on the orphan) — the split-brain that produces
intermittent SQLITE_CORRUPT / SQLITE_IOERR. Stop the writers; do not
unlink the WAL yourself. ``database.journal_mode: delete`` is operator
containment, not a default change.
Subclasses :class:`StateDbReplacedError` so every downstream consumer
that already stops SQLite writes and diverts pending transcripts on a
replaced store (gateway retry queue, run_agent flush) handles the split
WAL generation identically — the correct response is the same: stop
writing, preserve the transcript tail on disk.
"""
# SQLite header: 4-byte big-endian application_id at offset 68. Distinct from
# inode: ``cp`` onto the same path keeps st_ino and truncates+rewrites.
_STATE_DB_APPLICATION_ID_OFFSET = 68
_STATE_DB_GENERATION_KEY = "db_file_generation"
_STATE_DB_REPLACED_MSG = (
"FATAL: state.db was replaced underneath the gateway; refusing further "
"writes to this file. Divert transcripts to sessions/<id>.jsonl (and the "
"gateway pending_messages spool) and restore or reopen after operator "
"intervention."
)
_DELETED_WAL_GENERATION_MSG = (
"FATAL: a live process holds a deleted state.db-wal or state.db-shm "
"inode while the path names a different (or missing) generation. "
"Refusing to open or write so a second WAL cannot be minted. "
"Stop the gateway, dashboard, and cron writers that hold the deleted "
"sidecar, then reopen. Do not delete the WAL yourself. "
"database.journal_mode: delete is operator containment, not a new default."
)
class StateDbCorruptError(sqlite3.DatabaseError):
"""A live SessionDB observed structural (non-FTS) corruption and is quarantined.
Raised once a write on this handle reports bare ``SQLITE_CORRUPT`` /
``SQLITE_NOTADB`` that is neither FTS-scoped (``_is_fts_write_corruption_error``)
nor a replaced-file case (``StateDbReplacedError``). Subclasses
``sqlite3.DatabaseError`` so every existing ``except sqlite3.Error``
degrade path keeps working; ``sqlite_errorcode``/``sqlite_errorname``
are copied from the originating error.
The quarantine is sticky for the life of the handle: later writes fail
fast, the handle never reopens after ``close()``, and ``close()`` skips
its own WAL checkpoint. Field evidence (the #90837 lost/reordered-page
signature, the #90950 page-1 clobber): a handle that kept writing for ~50
minutes after the first structural error checkpointed 15 pages under the
wrong page numbers on shutdown, turning a still-readable file into
``file is not a database``. Stopping the writes is what prevents that;
skipping the explicit checkpoint is the second line of defence. SQLite
still runs its own last-connection checkpoint inside ``close()`` (and
deletes the ``-wal`` sidecar) unless ``SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE``
is set — Python exposes it via ``Connection.setconfig()`` on 3.12+, so
quarantine disables the close-time checkpoint there and the WAL survives
on disk for forensics; on 3.11 the internal checkpoint is unavoidable
(post-quarantine it can only carry pre-corruption committed frames, since
no further writes are accepted). The
recovery boundary is a process restart on a repaired or restored file.
"""
_STATE_DB_CORRUPT_MSG = (
"FATAL: state.db reported structural corruption (database disk image is "
"malformed outside the FTS shadow tables) on a live handle; refusing further "
"writes, automatic reopen, and the close-time WAL checkpoint on this file. "
"Stop the gateway, then run `hermes sessions recover --source <state.db> "
"--inspect-only` or restore a snapshot. Unwritten transcripts are diverted to "
"sessions/<id>.jsonl (and the gateway pending_messages spool)."
)
def divert_session_transcript_jsonl(session_id: str, messages) -> "Optional[Path]":
"""Append pending messages as JSON lines under HERMES_HOME/sessions.
Used when state.db is replaced under a live process so the current
turn is not only in RAM. Returns the jsonl path, or None when there
is nothing to write.
"""
sid = str(session_id or "").strip()
if not sid or not messages:
return None
sessions_dir = get_hermes_home() / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
path = sessions_dir / f"{sid}.jsonl"
with path.open("a", encoding="utf-8") as handle:
for msg in messages:
if isinstance(msg, dict):
handle.write(json.dumps(msg, ensure_ascii=False, default=str) + "\n")
elif msg is not None:
handle.write(json.dumps({"content": str(msg)}, ensure_ascii=False) + "\n")
return path
# ── Process-wide shared SessionDB registry (#90837) ──
#
# The registry itself lives in hermes_state_registry.py — a bounded
# module owning acquisition, generation identity, refcounting,
# retirement, and teardown. These re-exports keep the historical
# import path (``from hermes_state import get_shared_session_db``)
# working for every call site and test that imports from here.
#
# Routing rules (see hermes_state_registry for the full lifecycle):
# - Long-lived in-process callers (gateway, tui_gateway, cron,
# in-process tools) share ONE writer connection per resolved path
# via get_shared_session_db().
# - CLI one-shots, recovery flows, and read-only cross-profile opens
# keep using SessionDB() directly with their own close().
from hermes_state_registry import ( # noqa: F401 (re-export)
close_shared_session_dbs,
get_shared_session_db,
release_or_close,
release_shared_session_db,
)
# ── Read-only health/stats probes (hermes doctor, dashboards) ──────────
# Lifecycle statuses surfaced by session pickers. Classification looks ONLY at
# a session's final message row — role, whether it carries tool_calls, and its
# finish_reason — so it stays O(1) per session (see
# SessionDB.session_lifecycle_statuses).
SESSION_STATUS_COMPLETE = "complete"
SESSION_STATUS_INTERRUPTED = "interrupted"
SESSION_STATUS_ERROR = "error"
SESSION_STATUS_EMPTY = "empty"
# finish_reason values that mark the turn as having ended in a provider or
# agent error (vs. a normal 'stop'/'length'/'tool_calls' completion).
_ERROR_FINISH_REASONS = frozenset({"error", "agent_error", "content_filter"})
def classify_session_status(
role: Optional[str],
has_tool_calls: bool,
finish_reason: Optional[str],
) -> str:
"""Classify a session's lifecycle from the shape of its final message.
- assistant with a normal finish → ``complete``
- assistant that still has pending tool_calls (no tool result row ever
followed, or it would be the last row instead) → ``interrupted``
- user or tool as the last row → ``interrupted`` (the agent never got to
answer / never consumed the tool result)
- an error finish_reason on the last row → ``error``
- anything unrecognized → ``complete`` (benign default; pickers must not
alarm on unknown shapes)
"""
if (finish_reason or "").strip().lower() in _ERROR_FINISH_REASONS:
return SESSION_STATUS_ERROR
r = (role or "").strip().lower()
if r == "assistant":
# The last row being an assistant message WITH tool_calls means the
# matching tool result never landed — an interrupted tool turn.
return SESSION_STATUS_INTERRUPTED if has_tool_calls else SESSION_STATUS_COMPLETE
if r in {"user", "tool"}:
return SESSION_STATUS_INTERRUPTED
return SESSION_STATUS_COMPLETE
# Parent→child ``profile_name`` inheritance fence (#88381). ``agent:<ns>:...``
# gateway keys encode the profile namespace; a keyless row (CLI / subagent
# lineage) carries none and inherits freely. Two keyed rows must agree on
# ``agent:<ns>:`` — a default child (``agent:main:``) forked from a sibling
# profile's row must not be durably mislabelled as that profile's.
_SAME_KEY_NAMESPACE_SQL = (
"p.session_key IS NULL OR sessions.session_key IS NULL"
" OR substr(p.session_key, 1, instr(substr(p.session_key, 7), ':') + 6)"
" = substr(sessions.session_key, 1, instr(substr(sessions.session_key, 7), ':') + 6)"
)
class SessionDB(
SessionSearchMixin,
SessionSchemaMixin,
SessionPortabilityMixin,
SessionTelegramTopicsMixin,
SessionCompressionMixin,
SessionGatewayMixin,
SessionMaintenanceMixin,
SessionUsageMixin,
SessionTitlesMixin,
SessionMessagesMixin,
):
"""
SQLite-backed session storage with FTS5 search.
Thread-safe for the common gateway pattern (multiple reader threads,
single writer via WAL mode). Each method opens its own cursor.
"""
# Only these state-owned producers participate in automatic stale-open
# reconciliation. Messaging-platform and UI/desktop sources have separate
# lifecycle owners; unknown/future sources fail closed (#60609).
_AUTO_PRUNE_STALE_OPEN_SOURCES: Tuple[str, ...] = (
"cli",
"cron",
"kanban",
"acp",
"api_server",
"subagent",
"tool",
)
# ── Write-contention tuning ──
# With multiple hermes processes (gateway + CLI sessions + worktree agents)
# all sharing one state.db, WAL write-lock contention causes visible TUI
# freezes. SQLite's built-in busy handler uses a deterministic sleep
# schedule that causes convoy effects under high concurrency.
#
# Instead, we keep the SQLite timeout short (1s) and handle retries at the
# application level with random jitter, which naturally staggers competing
# writers and avoids the convoy.
#
# Patience is TIME-based, not attempt-based. A shared state.db is
# legitimately held for multi-second stretches by sibling Hermes
# processes: a TRUNCATE checkpoint at close on a large WAL, VACUUM after
# an auto-prune, offline recovery, or an older still-running process
# whose FTS maintenance predates the bounded-merge protocol (every
# `hermes update` leaves mixed-version processes sharing the DB until
# the old ones exit). An attempt-counted budget (~15s incidental worst
# case) silently loses that race and surfaces as
# session_persistence_failed — a destroyed turn — even though the store
# is healthy and merely busy (#74478).
#
# Two budgets: routine writes give up after _WRITE_PATIENCE_S so
# background/UI callers don't stall excessively, while transcript
# writes (append_message / session-row creation — the ones whose
# failure aborts the user's turn) ride out anything shorter than
# _TRANSCRIPT_WRITE_PATIENCE_S. Jitter stays small for the first
# _WRITE_RETRY_SLOW_AFTER_S (fast reclaim on millisecond contention),
# then backs off so a long hold isn't hammered with BEGIN IMMEDIATE
# attempts.
_WRITE_PATIENCE_S = 20.0
_TRANSCRIPT_WRITE_PATIENCE_S = 60.0
# Observation-only activity heartbeat/label writes (#76354 review S1):
# these run on (or adjacent to) the response-critical path and must never
# wait out the full routine patience under contention. Sub-second budget;
# a skipped write is retried naturally at the next heartbeat window.
_ACTIVITY_WRITE_PATIENCE_S = 0.5
# A live compression lock gets its own, much shorter budget than the write
# lock. Compression publishes in a couple of seconds, so a brief wait saves
# the overwhelming majority of concurrent turns (#75083). It deliberately
# stays short: the lease is a correctness boundary, not just a busy signal
# (see test_compression_lease_blocks_non_owner_but_allows_owner_flush), so
# a writer that is still locked out after this budget must still be
# refused rather than allowed to land a stale turn in a session whose
# compression is genuinely long-running or wedged.
_COMPRESSION_BUSY_WAIT_S = 5.0
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
_WRITE_RETRY_SLOW_AFTER_S = 2.0
_WRITE_RETRY_SLOW_MIN_S = 0.250 # 250ms
_WRITE_RETRY_SLOW_MAX_S = 1.000 # 1s
# Attempt a WAL checkpoint every N successful writes (PASSIVE mode).
_CHECKPOINT_EVERY_N_WRITES = 50
# Retain the existing coarse 1000-write maintenance cadence, but replace
# the unbounded FTS5 ``'optimize'`` (measured holding the write lock for
# 9-18 s per index on a 10 GB production DB — longer than a competing
# writer's full retry patience, surfacing as "database is locked" /
# session_persistence_failed) with bounded ``'merge'`` commands. A
# positive merge rank is an approximate output-page budget, so each
# command holds the write lock for milliseconds; up to
# ``_FTS_MERGE_COMMANDS_PER_PASS`` commands run per index per cadence,
# stopping early on the documented no-progress signal. ``usermerge`` is
# lowered to 2 so positive merges act on any level with >= 2 segments —
# without that, levels below the default threshold of 4 are skipped and
# a fragmented index never converges (SQLite FTS5 §6.8-6.9).
_FTS_MERGE_EVERY_N_WRITES = 1000
_FTS_MERGE_MAX_PAGES_PER_INDEX = 500
_FTS_MERGE_COMMANDS_PER_PASS = 4
# Session imports intentionally use a lower cap than exports: import holds
# one BEGIN IMMEDIATE transaction, so bounded batches avoid starving live
# gateway/CLI writers. The dashboard accepts one exported JSON/JSONL file
# at a time, so these still cover normal history restores.
_IMPORT_MAX_SESSIONS = 500
_IMPORT_MAX_MESSAGES_PER_SESSION = 10_000
_IMPORT_MAX_TOTAL_MESSAGES = 50_000
_IMPORT_MAX_SESSION_BYTES = 5 * 1024 * 1024
_IMPORT_MAX_TOTAL_BYTES = 25 * 1024 * 1024
# Demand-started accounting workers retire after an idle window so their
# bound targets do not keep abandoned SessionDB instances (and SQLite
# descriptors) alive forever. A later enqueue starts a fresh worker.
_TOKEN_WRITER_IDLE_SECONDS = 30.0
@staticmethod
def _store_system_prompt(conn, system_prompt: Optional[str]) -> Optional[str]:
if system_prompt is None:
return None
prompt_hash = _system_prompt_hash(system_prompt)
conn.execute(
"INSERT OR IGNORE INTO system_prompts (hash, prompt) VALUES (?, ?)",
(prompt_hash, system_prompt),
)
return prompt_hash
@staticmethod
def _delete_unreferenced_system_prompts(conn) -> None:
conn.execute(
"DELETE FROM system_prompts "
"WHERE NOT EXISTS ("
"SELECT 1 FROM sessions "
"WHERE sessions.system_prompt_hash = system_prompts.hash"
")"
)
@staticmethod
def _session_row_dict(row: sqlite3.Row) -> Dict[str, Any]:
data = dict(row)
if "_system_prompt_resolved" in data:
resolved = data.pop("_system_prompt_resolved")
if "system_prompt" in data:
data["system_prompt"] = resolved
return data
@staticmethod
def _close_connection_quietly(conn: Optional[sqlite3.Connection]) -> None:
"""Close a partially initialized connection without masking its error."""
if conn is None:
return
try:
conn.close()
except Exception:
logger.debug("Could not close a SessionDB connection", exc_info=True)
def __init__(self, db_path: Path = None, read_only: bool = False):
self.db_path = db_path or _default_db_path()
# Fail hard (before any connection/pragma/mkdir) if a pytest-context
# process resolved the developer's production state.db — see the
# live-DB test-isolation guard block near _default_db_path().
_ensure_test_isolation(self.db_path)
self.read_only = read_only
self._lock = threading.Lock()
# Read-path split (WAL only): recall/browse queries borrow a
# read-only connection from a bounded pool so they never queue
# behind writer flushes on self._lock. See _read_ctx().
#
# The pool is BOUNDED because the previous per-thread
# (threading.local + strong set) scheme pinned one connection per
# (SessionDB x thread) for the life of the process. Starlette
# dispatches sync routes on anyio worker threads, so a SessionDB
# that is never closed accumulated a connection — and two fds, the
# database and its -wal — for every worker thread that ever read,
# until the process hit the 256 soft RLIMIT_NOFILE a service manager
# hands it and every request failed with EMFILE while the process
# stayed alive, so the supervisor's restart-on-exit never fired.
# Same bug class as the closing(...) fix in gateway/readiness.py
# (#69678 / #69567).
self._read_pool: "queue.LifoQueue[sqlite3.Connection]" = queue.LifoQueue(
maxsize=_READ_POOL_MAX
)
# One permit per live read connection, held from before the open in
# _get_read_conn() until after the close in _close_read_conn(). This
# is what bounds PEAK descriptors; _read_pool alone bounds only the
# idle set. See _READ_POOL_MAX. Acquired non-blocking on purpose: a
# reader that cannot get a permit must degrade to the writer lock, not
# queue here — blocking would convert fd exhaustion into a stall, which
# is the same outage with a different stack trace.
#
# Permits are shared per DATABASE PATH, not per instance: the
# descriptors they ration belong to the file, and one process holds
# several SessionDB objects on the same state.db (#98573). See
# _PathReadBudget.
self._read_budget = _read_budget_for(self.db_path)
self._read_budget.register(self)
# Bound to the semaphore itself so every release site
# (_close_read_conn and the _get_read_conn failure paths) is unchanged.
self._read_permits = self._read_budget.permits
# Count of reads that found no permit and fell back to the locked
# writer connection. Not load-bearing; it is the only externally
# visible signal that the ceiling is actually being reached, so a
# too-small _READ_POOL_MAX is diagnosable from a running process
# instead of inferred from latency.
self._read_permit_exhausted = 0
self._read_conns_lock = threading.Lock()
# Set when close() begins. _read_ctx checks this under the lock
# before returning a connection to the pool, so a reader still in
# flight during the drain closes its own connection instead of
# re-populating a pool nobody will drain again.
self._read_conns_closed = False
# "read-only opens are failing against this file" backoff stamp.
# Instance-wide rather than per-thread: with a shared pool the open
# is no longer a per-thread event, and retrying a known-bad open on
# every query is a syscall storm for no benefit. The locked writer
# connection still serves reads while the backoff holds.
# Deliberately a TIMESTAMP, not a sticky bool: the likeliest trigger
# is transient fd pressure (EMFILE) — the very condition this pool
# exists to prevent — and a permanent flag would demote every reader
# on this instance to the writer lock for the life of the process.
# The gateway shares one SessionDB across every agent, so that turns
# a momentary blip into a permanent global convoy. Expires after
# _READ_OPEN_RETRY_SECONDS so the read path self-heals.
self._read_open_failed_at = 0.0
self._wal_active = False
self._write_count = 0
# File identity of the state.db this instance opened. Compared on
# every write (and before FTS fail-open / reopen-after-close) so an
# out-of-band replace cannot limp through in-place surgery.
# Inode catches mv/new-file; application_id catches cp onto the
# same path (same inode, truncate+rewrite).
self._db_file_identity: Optional[tuple] = None
self._db_file_application_id: int = 0
self._db_file_generation_token: str = ""
self._db_replaced = False
# Sticky: set once a write on THIS handle reports bare SQLITE_CORRUPT /
# NOTADB that is not FTS-scoped and not a replaced-file case. Never
# cleared; the recovery boundary is a process restart on a repaired or
# restored file (see StateDbCorruptError).
self._db_corrupt = False
self._db_corrupt_reason = ""
self._db_sidecar_identity: Dict[str, tuple] = {}
self._db_wal_generation_lost = False
# One-shot guard for the usermerge-floor config write on the
# incremental FTS merge cadence (see _merge_fts_incrementally).
self._fts_usermerge_floor_applied = False
self._fts_enabled = False
self._fts_stale = False
self._trigram_available = False
# CJK-bigram index (cjk_unicode61 loadable tokenizer). _fts_cjk_loaded:
# extension present on the writer connection; _fts_cjk_available: the
# messages_fts_cjk table is queryable AND not marked stale. Set during
# _init_schema / _probe_fts_cjk.
self._fts_cjk_loaded = False
self._fts_cjk_available = False
self._fts_unavailable_warned = False
self._conn = None
# Async token accounting (see queue_token_counts). The condition
# guards queue + writer state; it is distinct from self._lock so
# enqueue/flush bookkeeping never contends with SQLite writes.
self._token_queue: deque = deque()
self._token_queue_cond = threading.Condition(threading.Lock())
self._token_writer_thread: Optional[threading.Thread] = None
self._token_writer_stop = False
self._token_writer_busy = False
self._token_atexit_hook: Optional[Callable[[], None]] = None
# Set True when this instance is opened via get_shared_session_db().
# Makes close() a no-op so the registry (not individual callers)
# controls the connection lifecycle (#90837).
self._shared_registry_owned = False
initialization_complete = False
try:
if read_only:
# Read-only attach for cross-profile aggregation: SELECT-only,
# so we skip schema init entirely (no DDL, no FTS probe, no
# column reconcile). Crucially this takes NO write lock, so
# polling another profile's live DB on every sidebar refresh
# never contends with that profile's running backend. The DB
# must already exist + be initialised (callers guard on
# db_path.exists()); a SELECT against an empty file raises and
# the caller degrades per-profile.
open_attempt = 0
while True:
try:
self._conn = _connect_tracked_db(
f"file:{self.db_path}?mode=ro",
tracking_path=self.db_path,
uri=True,
check_same_thread=False,
timeout=1.0,
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
# FTS capability flags normally come from writable schema
# initialisation. Probe existing virtual tables with
# SELECTs only so read-only search keeps its FTS and
# trigram paths. Close the connection on ANY probe
# failure (e.g. malformed schema raises DatabaseError,
# not the OperationalError the probe handles). The
# constructor's outer finally also covers failures
# before this probe and BaseException paths, so a
# leaked tracked connection cannot block
# _backup_db_file's raw-copy for the rest of the
# process — the writable heal that follows would then
# repair WITHOUT its forensic backup.
try:
apply_database_pragmas(self._conn, db_label="state.db")
cursor = self._conn.cursor()
self._fts_enabled = (
self._fts_table_probe(cursor, "messages_fts")
is True
)
if self._fts_enabled:
self._trigram_available = (
self._fts_table_probe(
cursor,
"messages_fts_trigram",
)
is True
)
except BaseException:
conn, self._conn = self._conn, None
try:
conn.close()
except Exception:
pass
raise
break
except sqlite3.OperationalError as ioerr:
# A WAL checkpoint / reset / frame-flush in flight on
# the writer side can surface SQLITE_IOERR to a
# concurrent mode=ro reader (it cannot perform the
# recovery the read needs — recovery writes the -shm
# index, which mode=ro refuses). The transition closes
# in milliseconds, so retry a bounded number of times
# before classifying the store as failed (#100436).
if not _is_transient_read_only_ioerr(
ioerr, attempt=open_attempt
):
raise
open_attempt += 1
time.sleep(_READ_ONLY_IOERR_RETRY_BACKOFF_S)
self._record_db_file_identity()
initialization_complete = True
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# Read-only file/sidecar preflight (port of kilocode#12508):
# repair-or-refuse BEFORE the first connection so users get an
# actionable message instead of an opaque "attempt to write a
# readonly database" from deep inside _init_schema.
if not read_only:
preflight_db_writability(self.db_path, db_label="state.db")
# #68474 / #97568: Serialize startup across zero-byte check, quarantine,
# connect, and schema commit so concurrent openers don't race on an
# absent-path -> connect -> schema-commit window.
needs_startup_guard = (
not read_only
and (
not self.db_path.exists()
or is_zeroed_state_db(self.db_path)
)
)
def _handle_quarantine_if_zeroed(already_locked: bool = False):
if (
self.db_path.exists()
and is_zeroed_state_db(self.db_path)
):
try:
zsize = self.db_path.stat().st_size
except OSError:
zsize = -1
qpath = quarantine_zeroed_state_db(
self.db_path, already_locked=already_locked
)
snaps = self.db_path.parent / "state-snapshots"
msg = (
f"state.db looks ZEROED ({zsize} bytes, no SQLite header). "
f"Preserved at {qpath or '(quarantine failed — file left in place)'}. "
f"Restore from {snaps} via `hermes snapshot list` / "
f"`hermes snapshot restore <id>` if available. "
"Opening a fresh empty database so the agent can start."
)
logger.error(msg)
_set_last_init_error(msg)
# If quarantine failed, do not open the zeroed file (would fail
# opaquely or risk further damage). Raise with the clear message.
if qpath is None and self.db_path.exists() and is_zeroed_state_db(self.db_path):
raise sqlite3.DatabaseError(msg)
def _connect_and_init():
# Refuse before sqlite3.connect (under the startup lock) so we
# cannot mint a replacement WAL while a live writer still
# holds a deleted sidecar inode.
refuse_deleted_wal_generation(self.db_path)
self._conn = _connect_tracked_db(
str(self.db_path),
check_same_thread=False,
# Short timeout — application-level retry with random
# jitter handles contention instead of sitting in
# SQLite's internal busy handler for up to 30s.
timeout=1.0,
# auto-starts transactions on DML, which conflicts with
# our explicit BEGIN IMMEDIATE. None = we manage
# transactions ourselves.
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
self._wal_active = (
apply_wal_with_fallback(self._conn, db_label="state.db") == "wal"
)
apply_database_pragmas(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")
self._fts_cjk_loaded = load_fts5_cjk_extension(self._conn)
self._init_schema()
def _connect_and_init_with_lock_patience():
# Lock contention during open: _init_schema's DDL/reconcile
# statements run on a 1s-timeout connection with no retry, so
# a sibling process holding the write lock (VACUUM, TRUNCATE
# checkpoint at close, a long FTS pass from an older
# still-running install) used to fail the ENTIRE open —
# callers then disable persistence for the whole run
# ("Failed to initialize SessionDB ... database is locked",
# #74478). The store is healthy; wait it out with the same
# jittered patience the write path uses. Non-lock errors
# (including the malformed class) propagate immediately.
deadline = time.monotonic() + self._WRITE_PATIENCE_S
while True:
try:
_connect_and_init()
return
except sqlite3.OperationalError as exc:
err = str(exc).lower()
if "locked" not in err and "busy" not in err:
raise
try:
if self._conn is not None:
self._conn.close()
except Exception:
pass
now = time.monotonic()
if now >= deadline:
raise
time.sleep(
min(
random.uniform(
self._WRITE_RETRY_SLOW_MIN_S,
self._WRITE_RETRY_SLOW_MAX_S,
),
max(deadline - now, 0.001),
)
)
def _open_with_optional_startup_guard():
if needs_startup_guard:
with quarantine_cross_process_lock(self.db_path) as lock_acquired:
if not lock_acquired:
logger.warning(
"startup quarantine lock for %s not acquired within 5s; proceeding",
self.db_path,
)
_handle_quarantine_if_zeroed(already_locked=lock_acquired)
_connect_and_init_with_lock_patience()
else:
_handle_quarantine_if_zeroed(already_locked=False)
_connect_and_init_with_lock_patience()
try:
_open_with_optional_startup_guard()
except sqlite3.DatabaseError as exc:
# The malformed-schema class (e.g. a duplicate sqlite_master
# row for messages_fts) fails on the very first statement —
# before _init_schema can run — so it can't be caught at the
# FTS-rebuild layer. Recover by repairing sqlite_master in
# place (backup first; canonical sessions/messages preserved),
# then reopen once. This is what lets Desktop/Dashboard
# self-heal instead of silently showing "no sessions".
if not is_malformed_schema_error(exc) or not _claim_repair_attempt(self.db_path):
raise
logger.error(
"state.db schema is malformed (%s) — attempting automatic "
"repair (a backup copy is made first).", exc,
)
try:
if self._conn is not None:
self._conn.close()
except Exception:
pass
report = repair_state_db_schema(self.db_path)
if not report.get("repaired"):
raise
_connect_and_init_with_lock_patience()
# NOTE: the v23 FTS optimization is OPT-IN (`hermes db optimize`),
# never auto-started on open. Legacy installs keep their working
# v22 inline FTS untouched here; only the explicit foreground
# command demotes + rebuilds. This avoids a background worker
# racing session lifecycle and the surprise disk/latency cost on
# an unattended open. (An interrupted optimize resumes when the
# user re-runs the command.)
self._ensure_db_file_generation()
self._record_db_file_identity()
initialization_complete = True
except Exception as exc:
# Capture the cause so /resume and friends can surface WHY the
# session DB is unavailable instead of a bare "Session database
# not available." Callers that catch this exception keep their
# existing ``self._session_db = None`` degradation path.
#
# Note: we deliberately do NOT clear _last_init_error on the
# success path (no else branch). In multi-threaded callers
# (gateway, web_server per-request SessionDB()), a concurrent
# successful open racing past this failure would erase the
# cause that another thread's /resume is about to format.
# Tests that need to reset the state can call
# ``hermes_state._set_last_init_error(None)`` explicitly.
_set_last_init_error(f"{type(exc).__name__}: {exc}")
raise
finally:
if not initialization_complete:
conn, self._conn = self._conn, None
self._close_connection_quietly(conn)
# ── Read-path split ──
def _get_read_conn(self) -> Optional[sqlite3.Connection]:
"""Open a fresh read-only connection, or None when unavailable.
Callers must return the connection to self._read_pool (see
_read_ctx); this opens, it does not track.
Only used under WAL: WAL readers see a consistent snapshot and never
block on (or get blocked by) the writer, so recall/browse queries can
skip self._lock entirely. Under DELETE journal mode (NFS fallback) a
reader can hit SQLITE_BUSY storms during writes, so we keep the
legacy locked single-connection path there.
Fresh read transactions begin per statement (autocommit), so each
query observes everything committed so far — read-your-writes holds
for the flush-then-search patterns in a turn.
"""
if not self._wal_active or self.read_only:
return None
with self._read_conns_lock:
if self._read_conns_closed:
return None
if (
self._read_open_failed_at
and time.monotonic() - self._read_open_failed_at
< _READ_OPEN_RETRY_SECONDS
):
return None
# Take the descriptor permit BEFORE the open, so concurrent openers
# race for permits rather than for file descriptors. Non-blocking:
# losing the race means "use the writer connection", not "wait".
if not self._read_budget.acquire(self):
with self._read_conns_lock:
self._read_permit_exhausted += 1
logger.debug(
"read pool at capacity (%d) for %s; serving this read from the "
"locked writer connection",
_READ_POOL_MAX,
self.db_path,
)
return None
# Bound before the try: the except handlers close it if the open
# half-succeeded, and an unbound name there would raise NameError over
# the top of the real failure.
conn = None
try:
conn = _connect_tracked_db(
f"file:{self.db_path}?mode=ro",
tracking_path=self.db_path,
uri=True,
# Pooled connections are borrowed by whichever thread runs
# the next read, and sqlite3 otherwise refuses cross-thread
# use ("SQLite objects created in a thread can only be used
# in that same thread") — including on close(), which is how
# the old per-thread connections became unclosable and leaked
# their fds. Exclusive ownership is enforced by the pool
# checkout/return, not by sqlite3. Matches the writer opens.
check_same_thread=False,
timeout=5.0,
isolation_level=None,
)
conn.row_factory = sqlite3.Row
apply_database_pragmas(conn, db_label="state.db")
# Load the CJK tokenizer extension on this connection so
# messages_fts_cjk queries work on the read path. The .so
# registers the tokenizer in the connection's in-memory
# registry, not the database file, so mode=ro is fine.
if self._fts_cjk_loaded:
load_fts5_cjk_extension(conn)
except sqlite3.Error:
# A partially-constructed connection — _connect_tracked_db
# succeeded, the CJK extension load did not — must be closed here.
# Dropping it on the floor still open leaves a live descriptor the
# tracking registry still counts: the same leak shape this pool
# exists to fix, one level further down.
self._discard_partial_read_conn(conn)
# Back off from retrying the open on every query; the locked
# writer connection still serves reads until the stamp expires.
with self._read_conns_lock:
self._read_open_failed_at = time.monotonic()
logger.debug("read-only connection open failed for %s", self.db_path, exc_info=True)
self._read_budget.release()
return None
except BaseException:
# Anything else (a non-sqlite3 extension-load failure, MemoryError,
# KeyboardInterrupt landing between open and return) must not
# strand the permit: a stranded permit is not a transient error, it
# permanently shrinks the read path by one slot for the life of the
# process.
self._discard_partial_read_conn(conn)
self._read_budget.release()
raise
return conn
def _evict_one_idle_read_conn(self) -> bool:
"""Close one connection sitting idle in this instance's pool.
Called by _PathReadBudget when a peer SessionDB on the same file wants
a permit this instance is holding but not using. Only the idle set is
reachable from here — a checked-out connection is not in the queue —
so this can never pull a connection out from under a live reader.
Returns whether a connection (and therefore a permit) was released.
"""
try:
conn = self._read_pool.get_nowait()
except queue.Empty:
return False
self._close_read_conn(conn)
return True
def _discard_partial_read_conn(self, conn) -> None:
"""Close a connection that failed between open and hand-off.
Separate from _close_read_conn because that one releases a permit and
this runs on paths that release their own.
"""
if conn is None:
return
try:
conn.close()
except Exception as exc:
logger.warning(
"partially-opened read conn close failed for %s: %s", self.db_path, exc
)
def _close_read_conn(self, conn) -> None:
"""Close a pooled read connection and release its descriptor permit.
This was a bare ``except Exception: pass``, which silently swallowed
the sqlite3.ProgrammingError raised when close() ran on a thread
other than the one that opened the connection — the exact signature
of the fd leak this pool fixes. A close that fails leaks a tracked
fd, so it must not be invisible.
The permit is released even when close() raises: the descriptor is
already lost at that point, and withholding the permit too would turn
one leaked fd into a permanently narrower read path — failing twice for
one fault. The warning is the signal that matters.
Pairs with _get_read_conn(). Calling this on a connection that did not
come from there over-releases the BoundedSemaphore, which raises
ValueError rather than silently widening the ceiling.
"""
try:
conn.close()
except Exception as exc:
logger.warning("read-conn close failed for %s: %s", self.db_path, exc)
finally:
self._read_budget.release()
def _checkout_read_conn(self) -> Optional[sqlite3.Connection]:
"""Borrow a read connection from the pool, opening one on a miss.
The single acquisition seam for the read path: the WAL/read_only gate,
the pool checkout and the open-on-miss all live here, so there is
exactly one place to exercise (and one place for a caller to bypass by
accident). Returns None when the read path is unavailable and the
caller must fall back to the locked writer connection.
A pool hit costs no permit — the connection it hands back is already
holding one. Only the miss path can open, and only _get_read_conn() can
take a permit, so peak live connections is bounded by _READ_POOL_MAX no
matter how many threads miss simultaneously.
"""
if not self._wal_active or self.read_only:
return None
try:
return self._read_pool.get_nowait()
except queue.Empty:
return self._get_read_conn()
@contextmanager
def _read_ctx(self) -> Iterator[sqlite3.Connection]:
"""Yield a connection for read-only statements.
WAL: a read-only connection borrowed from a bounded pool with NO
lock — recall queries never convoy behind writer flushes (the
gateway shares one SessionDB across every agent, so this lock was a
global choke point). The connection is checked out for the duration
of the block, so no two threads ever touch it concurrently.
Non-WAL, read-conn failure, or _READ_POOL_MAX already reached: the
shared writer connection under self._lock, byte-for-byte the legacy
behavior.
That last case is the deliberate degradation. Past the ceiling readers
convoy on the writer lock instead of opening descriptors — measurably
slower under a burst, and the alternative is EMFILE, which takes the
whole process down in a way a restart-on-exit supervisor cannot see.
"""
conn = self._checkout_read_conn()
if conn is not None:
try:
yield conn
finally:
returned = False
with self._read_conns_lock:
if not self._read_conns_closed:
try:
self._read_pool.put_nowait(conn)
returned = True
except queue.Full:
pass
if not returned:
# close() has already drained the pool, so this connection
# is surplus. Close it here — dropping it on the floor is
# what leaked the fd.
#
# queue.Full is now unreachable in practice (permits and
# maxsize are both _READ_POOL_MAX, so there can never be a
# ninth connection to return), but the branch stays: it is
# load-bearing if those two ever drift apart, and a leak is
# the failure mode it prevents.
self._close_read_conn(conn)
return
with self._lock:
if self._conn is None:
# close() ran while a reader was still unwinding (#94736
# class) — reopen instead of yielding None to a .execute.
self._reopen_after_close_locked(context="read")
yield cast(sqlite3.Connection, self._conn)
def _reopen_after_close_locked(self, context: str = "write") -> None:
"""Reopen the writer connection after ``close()`` raced a live caller.
The #94736 failure shape: a teardown owner (cron ``run_job``'s
``finally``, a delegate timeout owner abandoning its worker, agent
``close()``) calls ``SessionDB.close()`` — which sets ``_conn = None``
— while a still-unwinding worker thread has one more transcript flush
to land. The next ``_execute_write`` then died on
``'NoneType' object has no attribute 'execute'``, the conversation
loop force-ended the turn as ``session_persistence_failed``, and the
in-flight tail of the subagent/cron session was silently dropped
(delivered as ``last_status: ok``).
The transcript append is THE critical write — losing it destroys the
turn — so at this shared persistence boundary we self-heal: reopen a
connection to the same database file and let the write land. The
reopen is loud (WARNING names the race) and bounded (only fires when
``_conn`` is ``None``, i.e. after an explicit ``close()`` — the
constructor never leaves a live instance with a ``None`` handle).
``__del__`` delegates to ``close()``, so the reopened connection is
still released at GC/exit time.
Caller must hold ``self._lock``. Raises ``sqlite3.OperationalError``
with an explicit cause when the reopen itself fails, so the surfaced
persistence error names the teardown race instead of the opaque
``NoneType`` attribute error.
"""
if self.read_only:
raise sqlite3.ProgrammingError(
f"SessionDB for {self.db_path} was closed (read-only handle); "
f"cannot serve a {context} after close()"
)
# A reopen resolves the PATH again — if the file at that path is no
# longer the one this instance originally opened (out-of-band
# restore/cp/mv), reconnecting would write into the new generation
# through stale WAL/shm assumptions (#89332). Refuse instead.
if self._db_replaced or self._db_file_was_replaced():
self._halt_db_replaced()
# A quarantined handle must never come back: reopening would hand a
# fresh connection (and its own close-time checkpoint) to a file we
# already know is structurally damaged.
if self._db_corrupt:
raise self._corrupt_error(
f"state.db connection for {self.db_path} is quarantined after "
f"structural corruption; refusing to reopen for a {context} "
"after close(). "
)
if self._db_wal_generation_lost or self._wal_generation_was_lost():
self._halt_deleted_wal_generation()
logger.warning(
"state.db connection for %s was closed while a %s was still in "
"flight — reopening (teardown/worker race, #94736)",
self.db_path,
context,
)
try:
conn = _connect_tracked_db(
str(self.db_path),
check_same_thread=False,
timeout=1.0,
isolation_level=None,
)
except Exception as exc:
raise sqlite3.OperationalError(
f"state.db connection was closed while a {context} was still "
f"in flight (a session-teardown path called close() before "
f"this worker finished — #94736) and the automatic reopen "
f"failed: {exc}"
) from exc
try:
conn.row_factory = sqlite3.Row
self._wal_active = (
apply_wal_with_fallback(conn, db_label="state.db") == "wal"
)
apply_database_pragmas(conn, db_label="state.db")
conn.execute("PRAGMA foreign_keys=ON")
self._fts_cjk_loaded = load_fts5_cjk_extension(conn)
except Exception as exc:
self._close_connection_quietly(conn)
raise sqlite3.OperationalError(
f"state.db reopen after close() succeeded but connection "
f"setup failed: {exc}"
) from exc
# Schema was initialised by this instance's original open; the file
# cannot have lost it, so no _init_schema here (no DDL races with
# sibling processes during teardown).
self._conn = conn
# ── Core write helper ──
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
if "no such module" in err and "fts5" in err:
return True
# SQLite builds that have FTS5 but lack the optional trigram tokenizer
# raise "no such tokenizer: trigram" instead of "no such module".
# Scope to trigram specifically to avoid masking unrelated tokenizer errors.
if "no such tokenizer: trigram" in err:
return True
# The cjk_unicode61 tokenizer is a loadable extension — a process
# that couldn't load it sees the same capability-error shape.
if "no such tokenizer: cjk_unicode61" in err:
return True
return False
@staticmethod
def _is_trigram_unavailable_error(exc: sqlite3.OperationalError) -> bool:
"""True when only an optional tokenizer is missing (FTS5 itself works).
Covers the built-in trigram tokenizer (needs SQLite >= 3.34) and the
loadable cjk_unicode61 tokenizer — both mean "this one index can't be
served here", never "disable FTS".
"""
err = str(exc).lower()
return (
"no such tokenizer: trigram" in err
or "no such tokenizer: cjk_unicode61" in err
)
@staticmethod
def _db_has_legacy_inline_fts(cursor: sqlite3.Cursor) -> bool:
"""True when messages_fts exists in ANY pre-v23 shape.
v23's messages_fts is external-content over THREE real columns
(content, tool_name, tool_calls). Every pre-v23 shape lacks the
tool_name/tool_calls columns — whether the old inline single-column
form (v11..v22) or the even older external-content single-column form
(v10-era, pre-#16751). We therefore detect "needs optimize" as "the
stored CREATE lacks the tool_name column", which is the precise v23
marker and correctly catches BOTH legacy variants.
Returns False when messages_fts doesn't exist yet (fresh DB mid-init):
the post-migration FTS setup block will create it in the v23 shape.
"""
row = cursor.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'messages_fts'"
).fetchone()
if row is None:
return False
sql = row[0] or ""
# The v23 table declares tool_name/tool_calls columns. Their absence
# means a legacy shape that doesn't index tool metadata → optimize.
return "tool_name" not in sql
def _warn_trigram_unavailable(self, exc: sqlite3.OperationalError) -> None:
"""Log once that the trigram tokenizer is missing; base FTS5 stays enabled."""
if getattr(self, "_trigram_unavailable_warned", False):
return
self._trigram_unavailable_warned = True
logger.info(
"SQLite trigram tokenizer unavailable for %s "
"(requires SQLite >= 3.34, this build is %s); "
"CJK/substring search will fall back to LIKE: %s",
self.db_path,
sqlite3.sqlite_version,
exc,
)
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
if self._fts_unavailable_warned:
return
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python (managed uv guarantees FTS5). "
"(underlying error: %s)",
self.db_path,
exc,
)
def _ensure_fts_cjk_schema(self, cursor) -> None:
"""Create / repair / self-heal the CJK-bigram index surface.
``cursor`` may be a Cursor or a Connection (both expose execute /
executescript). Called only for v23-shape DBs with the base FTS
surface healthy. Sets ``self._fts_cjk_available``. Never raises;
every failure mode degrades to "no cjk index" (trigram/LIKE routing
keeps working).
Cases:
tokenizer loaded, table absent → create. Empty DB: index is
complete by construction (triggers cover everything). Populated
DB: set the cjk backfill markers so the id-gated triggers stay
correct and `optimize-storage` can backfill; the index is NOT
served until the backfill completes.
tokenizer loaded, table present → ensure triggers (recreates any
dropped by a tokenizer-less process), honour the stale
breadcrumb (serve only when absent and no backfill pending).
tokenizer NOT loaded, table present with live triggers → drop the
cjk triggers so message INSERTs don't fail at trigger time,
and leave the stale breadcrumb (#self-heal). The table itself
stays for a later capable open to rebuild.
"""
try:
cjk_present = bool(cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = 'messages_fts_cjk'"
).fetchone())
if not self._fts_cjk_loaded:
if cjk_present:
live = [
r[0] for r in cursor.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' "
f"AND name IN ({','.join('?' for _ in _FTS_CJK_TRIGGERS)})",
_FTS_CJK_TRIGGERS,
).fetchall()
]
if live:
# Self-heal: this process cannot tokenize, so every
# message INSERT would die inside the cjk trigger.
# Breadcrumb FIRST (crash between the two statements is
# merely conservative), then drop.
logger.warning(
"messages_fts_cjk triggers present but the "
"cjk_unicode61 tokenizer is unavailable (%s) — "
"dropping the cjk triggers so message writes keep "
"working. CJK search falls back to trigram/LIKE; "
"run `hermes sessions optimize-storage` on a host "
"with the extension to rebuild.",
fts5_cjk_so_path(),
)
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, '1') "
"ON CONFLICT(key) DO UPDATE SET value = '1'",
(FTS_CJK_STALE_KEY,),
)
for trig in live:
cursor.execute(f"DROP TRIGGER IF EXISTS {trig}")
self._fts_cjk_available = False
return
except sqlite3.OperationalError:
# Mirror the tokenizer-loaded except below: the presence check
# and self-heal above run before the loaded/not-loaded branch is
# even known to be safe, so they need the same never-raises
# guarantee the docstring promises for the rest of the method.
logger.warning(
"messages_fts_cjk presence check failed; CJK search stays on "
"trigram/LIKE", exc_info=True,
)
self._fts_cjk_available = False
return
try:
cursor.executescript(FTS_CJK_TABLE_SQL)
if not cjk_present:
# Freshly created. An empty DB's index is complete by
# construction (triggers will cover every future row); a
# populated DB (e.g. a v23 install predating the cjk index)
# gets the dedicated marker pair so the id-gated triggers
# keep NEW rows indexed while old rows await the
# `optimize-storage` backfill. Either way any old stale
# breadcrumb refers to a table that no longer exists.
cursor.execute(
"DELETE FROM state_meta WHERE key = ?",
(FTS_CJK_STALE_KEY,),
)
n_msgs = cursor.execute(
"SELECT COUNT(*) FROM messages WHERE role <> 'tool'"
).fetchone()[0]
if n_msgs > 0:
hw = cursor.execute(
"SELECT COALESCE(MAX(id), 0) FROM messages"
).fetchone()[0]
for k, v in (
("fts_cjk_rebuild_high_water", str(hw)),
("fts_cjk_rebuild_progress", "0"),
):
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(k, v),
)
stale = cursor.execute(
"SELECT 1 FROM state_meta WHERE key = ?",
(FTS_CJK_STALE_KEY,),
).fetchone()
if stale:
# A tokenizer-less process dropped the triggers at some
# unknown point — the index has a gap of unknown extent.
# Do NOT reinstall triggers (an external-content 'delete'
# for an unindexed rowid corrupts the index); the next
# `optimize-storage` run rebuilds from scratch.
self._fts_cjk_available = False
return
cursor.executescript(FTS_CJK_TRIGGER_SQL)
backfill_pending = cursor.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_cjk_rebuild_high_water' LIMIT 1"
).fetchone()
self._fts_cjk_available = not backfill_pending
except sqlite3.OperationalError:
# Includes "no such tokenizer: cjk_unicode61" if the extension
# loaded but registration failed — degrade to trigram/LIKE.
logger.warning(
"messages_fts_cjk ensure failed; CJK search stays on "
"trigram/LIKE", exc_info=True,
)
self._fts_cjk_available = False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass
def _ensure_fts_schema(
self,
cursor: sqlite3.Cursor,
table_name: str,
ddl: str,
) -> bool:
status = self._fts_table_probe(cursor, table_name)
if status is None:
return False
try:
# Run even when the virtual table exists so any dropped or missing
# triggers are recreated after a previous no-FTS5 runtime disabled
# them to keep message writes working.
cursor.executescript(ddl)
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
# Only disable FTS entirely when the whole FTS5 module is missing.
# A missing specific tokenizer (e.g. trigram) means only that
# particular table cannot be created — the base FTS5 table is fine.
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
return False
def _execute_write(
self,
fn: Callable[[sqlite3.Connection], T],
patience_s: Optional[float] = None,
) -> T:
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
*fn* receives the connection and should perform INSERT/UPDATE/DELETE
statements. The caller must NOT call ``commit()`` — that's handled
here after *fn* returns.
BEGIN IMMEDIATE acquires the WAL write lock at transaction start
(not at commit time), so lock contention surfaces immediately.
On ``database is locked``, we release the Python lock, sleep a
random jitter, and retry — breaking the convoy pattern that
SQLite's built-in deterministic backoff creates.
*patience_s* is the total time budget for lock retries (default
``_WRITE_PATIENCE_S``). Transcript-critical writes pass
``_TRANSCRIPT_WRITE_PATIENCE_S`` so a sibling process holding the
lock for a legitimate long operation (VACUUM, TRUNCATE checkpoint,
pre-bounded-merge FTS optimize from an older still-running
install) exhausts routine writers' patience without destroying a
user turn. Jitter starts small (20-150ms) for fast reclaim on
millisecond contention and backs off to 250ms-1s once the lock has
been held longer than ``_WRITE_RETRY_SLOW_AFTER_S``.
Returns whatever *fn* returns.
"""
if patience_s is None:
patience_s = self._WRITE_PATIENCE_S
deadline = time.monotonic() + patience_s
# Set on the first compression-busy collision so the short wait is
# measured from then, not from the start of the write.
compression_deadline: Optional[float] = None
# One retry for SQLITE_IOERR raised by BEGIN IMMEDIATE itself. The
# callback has not run at that point, so there is no durable effect
# to replay and the retry is exactly-once safe (#99502's contract).
# Once the callback starts, an IOERR leaves the write's settlement
# unknown and must propagate — this helper owns non-idempotent
# transcript/counter mutations, not just idempotent UPSERTs.
ioerr_begin_retried = False
# Transient engine-level error observed on contended WAL appends
# (dual gateway/agent writers; FTS5 trigram sync holds the write
# lock). The identical write succeeds standalone, so it is
# retryable like locked/busy. The exception CLASS varies with the
# SQLite build — some surface it as InterfaceError, which lives
# OUTSIDE DatabaseError and escaped the retry net entirely on
# attempt 0 — so the check is message-scoped, not class-scoped.
def _is_no_more_rows(exc: sqlite3.Error) -> bool:
return "no more rows available" in str(exc).lower()
while True:
self._raise_if_db_corrupt()
self._raise_if_db_replaced()
fn_started = False
try:
with self._lock:
if self._conn is None:
# close() ran while this writer was still unwinding
# (#94736) — reopen instead of dying on None.execute.
self._reopen_after_close_locked(context="write")
self._conn.execute("BEGIN IMMEDIATE")
try:
fn_started = True
result = fn(self._conn)
self._conn.commit()
except BaseException:
try:
self._conn.rollback()
except Exception:
pass
raise
# Success — periodic best-effort checkpoint + FTS merge.
self._write_count += 1
if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
self._try_wal_checkpoint()
if self._write_count % self._FTS_MERGE_EVERY_N_WRITES == 0:
self._try_incremental_merge_fts()
return result
except SessionCompressionInProgressError:
# A live foreign compression lock is transient: the compressor
# publishes in a couple of seconds. Without any wait, a steer
# that lands mid-compression aborts the user's turn as
# session_persistence_failed and sends the operator hunting
# disk space that was never the problem (#75083).
#
# The budget is _COMPRESSION_BUSY_WAIT_S, not the write-lock
# patience: the lease is a correctness boundary, so a writer
# still locked out after a short wait must be refused rather
# than left to land a stale turn once a long-running or wedged
# compression finally lets go.
if compression_deadline is None:
compression_deadline = min(
time.monotonic() + self._COMPRESSION_BUSY_WAIT_S, deadline
)
if self._sleep_before_write_retry(
compression_deadline, self._COMPRESSION_BUSY_WAIT_S
):
continue
raise
except sqlite3.OperationalError as exc:
err_msg = str(exc).lower()
if "locked" in err_msg or "busy" in err_msg:
if self._sleep_before_write_retry(deadline, patience_s):
continue
# Patience exhausted — say what actually happened so the
# surfaced error doesn't read as disk/permission damage.
raise sqlite3.OperationalError(
f"database is locked (another Hermes process held the "
f"state.db write lock for over {patience_s:.0f}s — "
"likely a long maintenance operation such as VACUUM, "
"a large WAL checkpoint, or an older pre-update "
"process; the database itself is healthy)"
) from exc
if _is_no_more_rows(exc) and self._sleep_before_write_retry(deadline, patience_s):
continue
if (
_DISK_IO_ERROR_MARKER in err_msg
and not fn_started
and not ioerr_begin_retried
and self._sleep_before_write_retry(deadline, patience_s)
):
# BEGIN IMMEDIATE itself hit a transient WAL-transition
# IOERR. Nothing has been mutated, so retrying on the SAME
# connection replays nothing. Never close()+reopen to
# "heal" it: close() cancels this process's POSIX locks on
# the file for every sibling connection (howtocorrupt §2.2).
ioerr_begin_retried = True
continue
# Non-lock error, the callback already ran (settlement is
# unknown — do not replay), or patience exhausted.
raise
except sqlite3.DatabaseError as exc:
if _is_no_more_rows(exc) and self._sleep_before_write_retry(deadline, patience_s):
continue
# An out-of-band replace of state.db (restore/cp/mv under a
# live process) surfaces as this same corruption error class.
# In-file repair on a NEW file generation amplifies the
# damage (#89332) — halt writes on this handle instead.
if (
"not a database" in str(exc).lower()
or is_malformed_db_error(exc)
or self._is_fts_write_corruption_error(exc)
):
self._raise_if_db_replaced()
# Corrupt FTS shadow tables make every write raise the
# malformed/corrupt error class through the FTS sync triggers
# while the canonical messages table is intact. Never run a
# full-message FTS5 rebuild from this live persistence path:
# on a multi-gigabyte state.db that can hold the writer lock
# for minutes. Atomically detach the derived indexes instead,
# then retry the canonical write. The existing stale-open and
# explicit repair paths retain rebuild ownership.
if self._enter_fts_fail_open(exc):
continue
# Bare SQLITE_CORRUPT / NOTADB that survived the replaced-file
# check and the FTS-scoped fail-open is structural damage:
# quarantine the handle (see StateDbCorruptError).
if self._is_structural_corruption_error(exc):
self._halt_db_corrupt(exc)
raise
except sqlite3.Error as exc:
# Catch-all for builds that surface 'no more rows available'
# as InterfaceError (a sibling of DatabaseError, not a
# subclass) or another sqlite3.Error class outside the two
# handlers above. Message-scoped: anything else propagates
# untouched.
if _is_no_more_rows(exc) and self._sleep_before_write_retry(deadline, patience_s):
continue
raise
def _write_sql(
self,
sql: str,
params: Any = (),
*,
many: bool = False,
patience_s: Optional[float] = None,
) -> None:
"""Run one INSERT/UPDATE/DELETE through ``_execute_write``."""
def _do(conn):
(conn.executemany if many else conn.execute)(sql, params)
self._execute_write(_do, patience_s=patience_s)
def _write_rowcount(
self, sql: str, params: Any = (), *, patience_s: Optional[float] = None
) -> int:
"""Run one UPDATE/DELETE through ``_execute_write``; return rows changed.
Falls back to ``SELECT changes()`` when the driver reports an unknown
rowcount (None / negative).
"""
def _do(conn):
rowcount = conn.execute(sql, params).rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
return self._execute_write(_do, patience_s=patience_s)
def _read_one(self, sql: str, params: Any = ()) -> Optional[sqlite3.Row]:
"""``fetchone()`` of one read-only statement via ``_read_ctx``."""
with self._read_ctx() as conn:
return conn.execute(sql, params).fetchone()
def _read_all(self, sql: str, params: Any = ()) -> List[sqlite3.Row]:
"""``fetchall()`` of one read-only statement via ``_read_ctx``."""
with self._read_ctx() as conn:
return conn.execute(sql, params).fetchall()
def _ensure_db_file_generation(self) -> None:
"""Mint a once-per-file generation stamp (state_meta + application_id).
First opener wins via INSERT OR IGNORE. application_id is written
only when still 0 so racers converge on the same header value.
PASSIVE checkpoint only — never TRUNCATE (#45383).
"""
if self.read_only or self._conn is None:
return
token = uuid.uuid4().hex
try:
with self._lock:
self._conn.execute(
"INSERT OR IGNORE INTO state_meta (key, value) VALUES (?, ?)",
(_STATE_DB_GENERATION_KEY, token),
)
row = self._conn.execute(
"SELECT value FROM state_meta WHERE key = ?",
(_STATE_DB_GENERATION_KEY,),
).fetchone()
if row and row[0]:
token = str(row[0])
self._db_file_generation_token = token
current = 0
pragma_row = self._conn.execute("PRAGMA application_id").fetchone()
if pragma_row:
current = int(pragma_row[0] or 0)
if current == 0:
app_id = int(token[:8], 16) & 0x7FFFFFFF
if app_id == 0:
app_id = 1
self._conn.execute(f"PRAGMA application_id={app_id}")
current = app_id
self._db_file_application_id = current
try:
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
except sqlite3.Error:
pass
except sqlite3.Error as exc:
logger.debug("state.db generation stamp skipped: %s", exc)
def _record_db_file_identity(self) -> None:
"""Snapshot inode plus the on-disk generation header when present."""
self._db_file_identity = _stat_db_file_identity(self.db_path)
self._db_sidecar_identity = _stat_sqlite_sidecar_identity(self.db_path)
disk_id = _read_sqlite_application_id(self.db_path)
if disk_id:
self._db_file_application_id = disk_id
elif self._conn is not None and not self._db_file_application_id:
try:
pragma_row = self._read_one("PRAGMA application_id")
if pragma_row and pragma_row[0]:
self._db_file_application_id = int(pragma_row[0])
except sqlite3.Error:
pass
def _db_file_was_replaced(self) -> bool:
"""True when the path no longer names the file this instance opened."""
recorded = self._db_file_identity
if recorded is not None:
current = _stat_db_file_identity(self.db_path)
if current is None or current != recorded:
return True
recorded_app = int(self._db_file_application_id or 0)
if recorded_app:
disk_app = _read_sqlite_application_id(self.db_path)
# Header 0 means the WAL has not been checkpointed yet — not a
# replace. A copied Hermes DB that minted its own id is nonzero.
if disk_app and disk_app != recorded_app:
return True
return False
def _halt_db_replaced(self) -> None:
"""Stop writes and raise; do not run in-file repair on a new generation."""
self._db_replaced = True
logger.error(_STATE_DB_REPLACED_MSG)
raise StateDbReplacedError(_STATE_DB_REPLACED_MSG)
def _wal_generation_was_lost(self) -> bool:
"""True when the WAL/SHM generation this instance opened is gone.
Steady state (a sidecar generation is recorded): pure stat — a
recorded inode that is missing or replaced by a new file at the same
path means the generation split. No /proc walk on healthy writes.
Empty-identity state (fresh DB whose WAL appears only after open, or
identity cleared by a clean ``close()``): fall back to a
``/proc/self/fd`` deleted-fd probe, and adopt the current sidecars as
this handle's generation once the probe comes back clean. The full
``/proc/*/fd`` walk is reserved for
:func:`refuse_deleted_wal_generation` on open, where we must see
*foreign* deleted holders before ``sqlite3.connect`` mints a new WAL.
"""
recorded = self._db_sidecar_identity or {}
base = os.fspath(self.db_path)
if recorded:
for suffix, recorded_ident in recorded.items():
current = _stat_db_file_identity(Path(base + suffix))
if current is None or current != recorded_ident:
return True
return False
if not self._wal_active:
# No WAL on this handle (journal_mode=delete/truncate fallback):
# there is no sidecar generation to lose, and probing every write
# would put a /proc walk on the hot path of exactly the
# delete-mode deployments the field report used as containment.
return False
if sys.platform.startswith("linux"):
watched = _watched_sqlite_sidecar_paths(self.db_path)
fd_dir = f"/proc/{os.getpid()}/fd"
try:
for fd in os.listdir(fd_dir):
try:
target = os.readlink(f"{fd_dir}/{fd}")
except OSError:
continue
if " (deleted)" in target and _canonical_sqlite_path(target) in watched:
return True
except OSError:
return False
# Probe clean (or unavailable on this platform): adopt whatever
# sidecar generation exists now so subsequent writes use the cheap
# stat check.
current_identity = _stat_sqlite_sidecar_identity(self.db_path)
if current_identity:
self._db_sidecar_identity = current_identity
return False
def _halt_deleted_wal_generation(self) -> None:
"""Stop writes; do not mint or keep committing on a split WAL."""
self._db_wal_generation_lost = True
logger.error(_DELETED_WAL_GENERATION_MSG)
raise DeletedWalGenerationError(_DELETED_WAL_GENERATION_MSG)
def _raise_if_db_replaced(self) -> None:
if self._db_replaced:
raise StateDbReplacedError(_STATE_DB_REPLACED_MSG)
if self._db_wal_generation_lost:
raise DeletedWalGenerationError(_DELETED_WAL_GENERATION_MSG)
if self._db_file_was_replaced():
self._halt_db_replaced()
if self._wal_generation_was_lost():
self._halt_deleted_wal_generation()
@classmethod
def _is_structural_corruption_error(cls, exc: BaseException) -> bool:
"""Bare SQLITE_CORRUPT/NOTADB with no FTS provenance.
``_is_fts_write_corruption_error`` is the positive FTS classifier;
everything else in the ``corrupt`` bucket of
``classify_persistence_error`` is damage to a canonical B-tree, the
schema, or the freelist — never repairable from the live write path.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
if isinstance(exc, StateDbCorruptError):
return False
if cls._is_fts_write_corruption_error(exc):
return False
return classify_persistence_error(exc) == "corrupt"
def _corrupt_error(self, prefix: str = "") -> "StateDbCorruptError":
"""Build the quarantine error for this handle (message assembled once)."""
return StateDbCorruptError(
f"{prefix}{_STATE_DB_CORRUPT_MSG} (cause: {self._db_corrupt_reason})"
)
def _halt_db_corrupt(self, exc: BaseException) -> None:
"""Quarantine this handle and raise; never run in-file repair here."""
self._db_corrupt = True
self._db_corrupt_reason = str(exc)
self._disable_close_time_checkpoint()
logger.error(
"state.db %s reported structural corruption outside the FTS "
"indexes (%s); quarantining this handle: no further writes, no "
"automatic reopen, no explicit WAL checkpoint at close. Stop the "
"gateway and run `hermes sessions recover --source %s "
"--inspect-only`.",
self.db_path,
exc,
self.db_path,
)
err = self._corrupt_error()
for attr in ("sqlite_errorcode", "sqlite_errorname"):
value = getattr(exc, attr, None)
if value is not None:
setattr(err, attr, value)
raise err from exc
def _disable_close_time_checkpoint(self) -> None:
"""Best-effort: stop SQLite's own last-connection checkpoint on close.
Skipping our explicit ``PRAGMA wal_checkpoint(PASSIVE)`` in
``close()`` is not enough on its own: ``sqlite3.Connection.close()``
still runs SQLite's internal last-connection PASSIVE checkpoint and
unlinks the ``-wal``/``-shm`` sidecars. On the field incident's file
that close-time checkpoint is exactly what wrote 15 pages under the
wrong page numbers. Python 3.12+ exposes the switch as
``Connection.setconfig(SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE)``; on 3.11
neither the constant nor ``setconfig`` exists, so the internal
checkpoint remains (it can only carry pre-quarantine committed
frames — no further writes are accepted on this handle).
"""
flag = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
if flag is None:
return
conn = self._conn
setconfig = getattr(conn, "setconfig", None)
if conn is None or setconfig is None:
return
try:
setconfig(flag, True)
except Exception:
logger.debug(
"Could not disable SQLite's close-time checkpoint on the "
"quarantined handle for %s",
self.db_path,
exc_info=True,
)
def _raise_if_db_corrupt(self) -> None:
if self._db_corrupt:
raise self._corrupt_error()
def _sleep_before_write_retry(
self, deadline: float, patience_s: float
) -> bool:
"""Sleep one jitter interval if the patience budget still allows it.
Returns True when the caller should retry, False when *deadline* has
passed and the error should propagate. Jitter stays small for the
first ``_WRITE_RETRY_SLOW_AFTER_S`` (fast reclaim on millisecond
contention) and backs off after that, and never overshoots the
deadline by a full slow-jitter.
"""
now = time.monotonic()
if now >= deadline:
return False
elapsed = now - (deadline - patience_s)
if elapsed >= self._WRITE_RETRY_SLOW_AFTER_S:
jitter = random.uniform(
self._WRITE_RETRY_SLOW_MIN_S,
self._WRITE_RETRY_SLOW_MAX_S,
)
else:
jitter = random.uniform(
self._WRITE_RETRY_MIN_S,
self._WRITE_RETRY_MAX_S,
)
time.sleep(min(jitter, max(deadline - now, 0.001)))
return True
@staticmethod
def _is_fts_write_corruption_error(exc: sqlite3.DatabaseError) -> bool:
"""Return true only when SQLite identifies corruption as FTS-scoped.
Newer SQLite builds include ``fts5`` in the error text. Older builds
may emit only ``database disk image is malformed`` while exposing the
extended ``SQLITE_CORRUPT_VTAB`` result code. A bare
``SQLITE_CORRUPT``/malformed-image error is structural and must not
trigger live FTS maintenance: it does not prove that canonical B-trees
are intact.
"""
corrupt_vtab = getattr(sqlite3, "SQLITE_CORRUPT_VTAB", 267)
error_code = getattr(exc, "sqlite_errorcode", None)
if error_code is not None:
return error_code == corrupt_vtab
msg = str(exc).lower()
return msg.startswith("fts5:") and "corrupt structure" in msg
def _foreign_state_db_holders(self) -> List[Tuple[int, str]]:
"""Return foreign processes holding this DB or its WAL sidecars.
Automatic FTS repair is structural maintenance, not an ordinary WAL
write. It must not run while another process remains attached: a
sidecar reset under that holder can leave the two processes writing
through different WAL inodes.
A scan failure is represented as an unknown holder. Skipping optional
automatic maintenance is safer than assuming quiescence; canonical
writes continue through the stale-FTS fail-open path.
"""
# The split-brain mechanism requires POSIX unlink semantics: Windows
# refuses to replace SQLite sidecars while another process has them
# open. Avoid psutil.open_files() there; querying arbitrary Windows
# processes can block for minutes on device-backed handles.
if _IS_WINDOWS:
return []
if psutil is None:
return [(-1, "open-file scan unavailable")]
db_path = os.path.abspath(os.fspath(self.db_path))
watched = {
_canonical_sqlite_path(db_path),
_canonical_sqlite_path(db_path + "-wal"),
_canonical_sqlite_path(db_path + "-shm"),
}
holders: List[Tuple[int, str]] = []
# On Linux, read /proc/<pid>/fd symlinks directly. psutil's
# open_files() filters through isfile_strict(), which stats the
# literal path — for an unlinked WAL sidecar the kernel returns
# "/path/state.db-wal (deleted)" and stat fails, so the entry is
# silently dropped and the split-brain holder is never seen.
# /proc readlinks preserve the "(deleted)" suffix so _canonical can
# strip it and match.
if sys.platform.startswith("linux"):
try:
own_pid = os.getpid()
for pid_str in os.listdir("/proc"):
if not pid_str.isdigit():
continue
pid = int(pid_str)
if pid == own_pid:
continue
fd_dir = f"/proc/{pid}/fd"
try:
fds = os.listdir(fd_dir)
except OSError:
# Cannot read this process's fd table (different
# user, e.g. root gateway vs user desktop).
# /proc/<pid>/cmdline is world-readable by default,
# so check whether this is a Hermes process —
# only flag uninspectable holders that look like
# another Hermes instance, not every system daemon.
cmdline = _read_proc_cmdline(pid)
if cmdline is not None and _looks_like_hermes(cmdline):
holders.append((pid, f"uninspectable holder: {cmdline[:80]}"))
continue
for fd in fds:
try:
target = os.readlink(f"{fd_dir}/{fd}")
except OSError:
continue
if _canonical_sqlite_path(target) in watched:
holders.append((pid, target))
except Exception as exc:
logger.warning(
"Could not prove state.db has no foreign holders; "
"deferring automatic FTS maintenance: %s",
exc,
)
return holders or [(-1, f"open-file scan failed: {exc}")]
return holders
# macOS / BSD: use psutil.open_files(). macOS does not use the
# "(deleted)" suffix convention, so psutil's filtering is safe here.
try:
for process in psutil.process_iter(["pid", "open_files"]):
info = process.info
pid = int(info["pid"])
if pid == os.getpid():
continue
# psutil's as_dict() converts AccessDenied to None, which
# or-() turns into an empty iteration. On macOS this is
# acceptable: the gateway/desktop topology from the issue is
# Linux-specific (systemd units running as root).
for opened in info.get("open_files") or ():
path = getattr(opened, "path", "")
if path and _canonical_sqlite_path(path) in watched:
holders.append((pid, path))
except Exception as exc:
logger.warning(
"Could not prove state.db has no foreign holders; "
"deferring automatic FTS maintenance: %s",
exc,
)
return holders or [(-1, f"open-file scan failed: {exc}")]
return holders
def _enter_fts_fail_open(self, exc: sqlite3.DatabaseError) -> bool:
"""Detach corrupt FTS indexes so canonical writes can continue.
The stale breadcrumb and trigger removal commit atomically. Its
ordering is load-bearing: after triggers are absent, new canonical
rows create an index gap of unknown extent, so another process must
never reinstall the triggers without first rebuilding every row.
"""
if not self._fts_enabled or not self._is_fts_write_corruption_error(exc):
return False
self._raise_if_db_corrupt()
if self._db_replaced or self._db_file_was_replaced():
self._halt_db_replaced()
if self._db_wal_generation_lost or self._wal_generation_was_lost():
self._halt_deleted_wal_generation()
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
self._conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, '1') "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(FTS_STALE_KEY,),
)
cjk_triggers_present = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' "
f"AND name IN ({','.join('?' for _ in _FTS_CJK_TRIGGERS)}) "
"LIMIT 1",
_FTS_CJK_TRIGGERS,
).fetchone()
if cjk_triggers_present:
self._conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, '1') "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(FTS_CJK_STALE_KEY,),
)
self._drop_all_fts_triggers(self._conn.cursor())
self._conn.commit()
except BaseException:
self._conn.rollback()
raise
except sqlite3.Error as detach_exc:
logger.error(
"Could not detach corrupt FTS indexes; canonical write still "
"cannot proceed: %s",
detach_exc,
)
return False
self._fts_stale = True
self._fts_enabled = False
self._trigram_available = False
self._fts_cjk_available = False
logger.error(
"state.db FTS indexes remain corrupt (%s); disabled FTS sync and "
"retrying the canonical write. Search temporarily uses LIKE until "
"a later SessionDB open rebuilds the indexes.",
exc,
)
return True
def _try_wal_checkpoint(self) -> None:
"""Best-effort PASSIVE WAL checkpoint. Never raises.
Flushes committed WAL frames back into the main DB file without
requiring an exclusive lock. PASSIVE is safe for frequent
periodic use because it does not block concurrent writers and
cannot corrupt B-tree pages under I/O pressure.
PASSIVE does not truncate the WAL file — it stays at its
high-water mark. Explicit checkpoints on the shared ``state.db`` no
longer truncate the WAL; it is bounded by ``journal_size_limit`` and
the writer's natural post-checkpoint reset rather than by a TRUNCATE
at every close or maintenance command.
Previous TRUNCATE strategy caused B-tree corruption on large
databases (65K+ pages) due to the exclusive-lock I/O pressure
from checkpointing thousands of frames at once (issue #45383).
"""
if self._db_corrupt:
return # quarantined: never checkpoint over a damaged image
try:
with self._lock:
result = self._conn.execute(
"PRAGMA wal_checkpoint(PASSIVE)"
).fetchone()
if result and result[1] > 0:
logger.debug(
"WAL checkpoint: %d/%d pages checkpointed",
result[2], result[1],
)
except Exception as exc:
logger.warning("WAL checkpoint (PASSIVE) failed: %s", exc)
def __enter__(self) -> "SessionDB":
"""Enter a scope that closes this handle on the way out.
Ownership of a SessionDB should be released explicitly.
Historically an instance with a started token writer pinned ITSELF
(bound-method writer target plus a strong ``atexit`` drain hook), so
``__del__`` never ran for exactly the instances that leaked
descriptors (#88033). The writer now retires after an idle window
and the atexit hook holds only a weak reference, so abandoned
handles are eventually collectible — but "eventually, after the
idle window and a GC cycle" is not a release policy. Call sites
owning a handle are still expected to close it deterministically
(see the ownership comments in ``run_agent.py`` and
``tui_gateway/methods_session.py``).
This makes the correct usage the easy one, so an owning scope can be
exception-safe by construction rather than by remembering a
``try/finally``:
with SessionDB(path) as db:
db.append_message(...)
Purely additive: it changes nothing for callers that already call
``close()`` directly, and ``close()`` stays idempotent, so a scope
that closes early still exits cleanly.
"""
return self
def __exit__(self, exc_type, exc, tb) -> bool:
"""Close the handle, then let any exception propagate.
Returns False (never suppressing), so ``with`` here only manages the
descriptor lifetime and never swallows a caller's error.
"""
self.close()
return False
def close(self):
"""Close the database connection.
Drains queued token deltas first (the background writer needs the
connection). Writable connections then attempt a PASSIVE WAL
checkpoint (NOT TRUNCATE: transient per-cron-run connections close
many times an hour, and a TRUNCATE fires a full WAL reset that
races the gateway's live writer and tears B-tree pages — issue
#45383). Read-only connections never request a checkpoint.
When this instance is shared (opened via ``get_shared_session_db``),
``close()`` RELEASES one refcount instead of tearing down the
connection: the registry owns the lifecycle and only closes on the
final release (#90837). This prevents one caller's close from
tearing down the writer connection that other callers in the same
process are still using — while still letting legacy ``close()``
call sites return their reference instead of leaking it.
"""
if getattr(self, "_shared_registry_owned", False):
from hermes_state_registry import release
release(self)
return
self._stop_token_writer()
hook, self._token_atexit_hook = self._token_atexit_hook, None
if hook is not None:
atexit.unregister(hook)
# Drain the read-only connection pool. Setting the closed flag
# under the lock first means a reader still in flight closes its own
# connection on release instead of re-populating a pool that has
# already been drained.
with self._read_conns_lock:
self._read_conns_closed = True
while True:
try:
conn = self._read_pool.get_nowait()
except queue.Empty:
break
self._close_read_conn(conn)
with self._lock:
if self._conn:
if self._db_corrupt:
# Quarantined handle (see StateDbCorruptError): no explicit
# checkpoint over a damaged page image.
logger.warning(
"Skipping the close-time WAL checkpoint for %s: this "
"handle observed structural corruption (%s). Take a "
"snapshot of state.db, -wal and -shm before restarting, "
"then run `hermes sessions recover --source %s "
"--inspect-only`.",
self.db_path,
self._db_corrupt_reason,
self.db_path,
)
elif not self.read_only:
# PASSIVE, not TRUNCATE. Every cron run_agent opens+closes a
# transient SessionDB, so a TRUNCATE here fires a full WAL
# reset many times/hour, racing the gateway's long-lived
# writer on large WAL databases and tearing hot B-tree
# pages -- the #45383 corruption this class's own periodic
# checkpoint was already made PASSIVE to avoid. TRUNCATE
# belongs only on a sole-opener/quiescent connection.
try:
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
except Exception as exc:
logger.debug(
"WAL checkpoint (PASSIVE) at close failed: %s",
exc,
)
conn, self._conn = self._conn, None
self._close_connection_quietly(conn)
# A clean close of the last connection lets SQLite unlink the
# WAL/SHM sidecars — a legitimate end of this handle's sidecar
# generation, not a split (#94736 late writes must still
# self-heal). Drop the recorded generation so a teardown-race
# reopen re-adopts whatever exists then instead of halting.
self._db_sidecar_identity = {}
def __del__(self) -> None:
"""Safety net: close the connection if the caller forgot.
The async accounting worker retires when idle and its atexit hook
holds only a weak reference, so neither can pin an otherwise orphaned
instance. During interpreter teardown the order of module cleanup is
undefined, so every attribute access remains guarded.
Delegates to ``close()`` so the read pool, token writer, and atexit
hook are all cleaned up — not just the writer connection.
"""
if self.__dict__.get("_conn") is None:
return
try:
self.close()
except Exception:
pass
# ── Chunked FTS rebuild engine (v23 opt-in optimize) ──
#
# `optimize_fts_storage()` (the `hermes sessions optimize-storage`
# command) drops the legacy inline FTS indexes and backfills the new
# external-content ones. A single blocking rebuild measured ~16 minutes
# of held write lock on a real 25 GB DB, so the backfill runs in small
# chunks, each in its own short write transaction:
# - concurrent readers/writers are never starved (WAL stays small,
# each chunk checkpoints via the normal _execute_write cadence);
# - an interrupted run (Ctrl-C, crash) resumes from
# fts_rebuild_progress when the command is re-run;
# - multiple processes sharing the DB don't double-run it — each chunk
# claims work by compare-and-swap on fts_rebuild_progress, so even a
# concurrent second runner just interleaves chunks safely.
#
# THROTTLING (the part that keeps a live gateway sharing the DB
# responsive): a greedy chunk loop re-acquires BEGIN IMMEDIATE nearly
# back-to-back and can starve another process's writer into exhausting
# its lock retries (an early 5000-row/50ms version owned the write lock
# ~85% of the time and visibly froze concurrent CLI sessions on a large
# install). Two layers prevent that:
# 1. Small chunks (500 rows) — a foreground write queues behind a
# chunk for at most ~tens of ms.
# 2. Inter-chunk pause — the loop sleeps max(_FTS_REBUILD_MIN_PAUSE,
# chunk cost x _FTS_REBUILD_DUTY_FACTOR) between chunks, capping
# this process's share of DB bandwidth so concurrent writers always
# find open windows. This works cross-process (unlike any
# same-process activity stamp) because it bounds our own duty
# cycle unconditionally.
_FTS_REBUILD_CHUNK_ROWS = 500
_FTS_REBUILD_DUTY_FACTOR = 4.0 # sleep >= 4x chunk cost (≤20% duty)
_FTS_REBUILD_MIN_PAUSE = 0.2 # seconds — floor between chunks
# Demoted v22 FTS shadow tables awaiting teardown (see the v23 migration:
# DROP of a multi-GB FTS vtable blocks for minutes, so the migration
# demotes the vtable definitions out of sqlite_master and renames the
# orphaned shadow tables — now plain tables — to fts_v22_trash_*; the
# worker empties them in bounded chunks, then drops them cheaply).
_FTS_TRASH_PREFIX = "fts_v22_trash_"
# ── CJK-bigram index backfill (dedicated marker pair) ──
#
# Same chunk engine as the main deferred rebuild, but on the
# ``fts_cjk_rebuild_*`` markers so a cjk-only backfill (the common case:
# an already-optimized v23 DB gaining the cjk index) never gates the
# complete ``messages_fts`` / trigram triggers.
# ── Opt-in v23 FTS storage optimization (`hermes sessions optimize-storage`) ──
#
# This is the ONLY path that migrates an existing legacy (v22 inline) DB
# to the v23 external-content schema. It is deliberately foreground and
# user-invoked, never automatic, because it is disk-heavy and long. It
# runs the throttled/resumable chunk engine above to completion
# synchronously — demote → new schema → chunked backfill → chunked
# teardown — with progress callbacks, a disk preflight in the CLI
# wrapper, a VACUUM at the end, and a defensive schema_version bump.
def _has_fts_trash(self, conn) -> bool:
"""True when demoted v22 shadow tables are still awaiting teardown.
Caller must hold ``self._lock`` (or pass a migration-time cursor)."""
return bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name LIKE ? ESCAPE '\\' LIMIT 1",
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
).fetchone())
# =========================================================================
# Session lifecycle
# =========================================================================
_PROFILE_DIR_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _own_profile_name(self) -> Optional[str]:
"""The profile that owns THIS store, derived from ``db_path`` alone.
Every profile-tree ``state.db`` belongs to exactly one profile
(``<root>/state.db`` → ``default``,
``<root>/profiles/<name>/state.db`` → ``<name>``), so the derivation
is a single match, never a guess — the same contract
:meth:`backfill_null_session_profiles` and the web listing's
``row_profile`` stamp rely on. Path-based (not
``get_active_profile_name()``) on purpose: a gateway serving a
NON-launch profile opens that profile's store directly, and the row
must be stamped with the store's owner, not the serving process's
launch profile. Returns ``None`` for stores outside the profile tree
(explicit ``db_path`` in tests, ad-hoc copies) — those rows keep the
legacy NULL rather than a fabricated owner.
"""
try:
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root().resolve()
parent = Path(self.db_path).resolve().parent
if parent == root:
return "default"
if parent.parent == root / "profiles" and self._PROFILE_DIR_RE.match(
parent.name
):
return parent.name
except Exception:
logger.debug("own-profile derivation failed", exc_info=True)
return None
def _insert_session_row(
self,
session_id: str,
source: str,
model: str = None,
model_config: Dict[str, Any] = None,
system_prompt: str = None,
user_id: str = None,
session_key: Optional[str] = None,
chat_id: str = None,
chat_type: str = None,
thread_id: str = None,
parent_session_id: str = None,
cwd: str = None,
profile_name: Optional[str] = None,
git_repo_root: str = None,
origin_json: str = None,
display_name: str = None,
) -> None:
"""Insert a session row, enriching NULL metadata on conflict.
The gateway creates a bare row (source + user_id) before the agent
exists and the agent's later ``create_session`` carries the real
model/model_config/system_prompt; ``INSERT OR IGNORE`` dropped that
enrichment, so the upsert ``COALESCE``-fills columns that are still
NULL and never overwrites what an earlier writer set (a later bare
source="unknown" call cannot clobber a real source/model).
``chat_id``/``thread_id`` record the messaging origin so gateway
``/resume`` can prove an inactive row belongs to the caller's
chat/thread (IDOR scoping).
With ``parent_session_id`` (compression fork, delegate spawn, branch),
NULL ``cwd``/``git_repo_root``/``git_branch``/``profile_name`` are
backfilled from the parent — child creators historically did not
propagate them, so lineages dropped out of the project sidebar or were
aggregated as "default" on every fork. NULL-fill only. Compression
forks (parent ``end_reason='compression'``) also inherit the gateway
origin columns (user_id/session_key/chat_id/chat_type/thread_id/
display_name/origin_json) so a crash before the gateway re-records the
peer cannot strand the child without a routing mapping.
With no ``profile_name`` the row is stamped with THIS store's own
profile (:meth:`_own_profile_name`): every state.db belongs to exactly
one profile (the contract :meth:`backfill_null_session_profiles`
relies on), and profile-keyed consumers treat NULL as unowned, hiding
the session from the sidebar. Stores outside the profile tree derive
nothing and keep NULL — never guess.
"""
if not (profile_name or "").strip():
profile_name = self._own_profile_name()
def _do(conn):
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
conn.execute(
"""INSERT INTO sessions (
id, source, user_id, session_key, chat_id, chat_type, thread_id,
model, model_config, system_prompt, system_prompt_hash,
parent_session_id, cwd, profile_name, git_repo_root,
origin_json, display_name, started_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
model = COALESCE(sessions.model, excluded.model),
model_config = CASE
WHEN excluded.model_config IS NOT NULL
AND json_type(
sessions.model_config, '$._reset_from'
) IS NOT NULL
AND json_remove(
sessions.model_config, '$._reset_from'
) = '{}'
THEN json_set(
excluded.model_config,
'$._reset_from',
json_extract(
sessions.model_config, '$._reset_from'
)
)
ELSE COALESCE(
sessions.model_config, excluded.model_config
)
END,
system_prompt_hash = COALESCE(
sessions.system_prompt_hash,
excluded.system_prompt_hash
),
system_prompt = CASE
WHEN sessions.system_prompt_hash IS NULL
AND excluded.system_prompt_hash IS NOT NULL
THEN NULL
ELSE sessions.system_prompt
END,
session_key = COALESCE(sessions.session_key, excluded.session_key),
chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
cwd = COALESCE(sessions.cwd, excluded.cwd),
profile_name = COALESCE(sessions.profile_name, excluded.profile_name),
git_repo_root = COALESCE(sessions.git_repo_root, excluded.git_repo_root),
origin_json = COALESCE(sessions.origin_json, excluded.origin_json),
display_name = COALESCE(sessions.display_name, excluded.display_name)""",
(
session_id,
source,
user_id,
session_key,
chat_id,
chat_type,
thread_id,
model,
json.dumps(model_config) if model_config else None,
system_prompt_hash,
parent_session_id,
cwd,
profile_name,
git_repo_root,
origin_json,
display_name,
time.time(),
),
)
if system_prompt_hash is not None:
self._delete_unreferenced_system_prompts(conn)
if parent_session_id:
conn.execute(
f"""UPDATE sessions
SET cwd = COALESCE(sessions.cwd,
(SELECT p.cwd FROM sessions p
WHERE p.id = sessions.parent_session_id)),
git_repo_root = COALESCE(sessions.git_repo_root,
(SELECT p.git_repo_root FROM sessions p
WHERE p.id = sessions.parent_session_id)),
git_branch = COALESCE(sessions.git_branch,
(SELECT p.git_branch FROM sessions p
WHERE p.id = sessions.parent_session_id)),
profile_name = COALESCE(sessions.profile_name,
(SELECT p.profile_name FROM sessions p
WHERE p.id = sessions.parent_session_id
AND ({_SAME_KEY_NAMESPACE_SQL})))
WHERE id = ? AND parent_session_id IS NOT NULL""",
(session_id,),
)
# Belt-and-suspenders for gateway routing metadata (#59527):
# the gateway re-records the peer on the child after rotation
# (d5b4879d4), but a hard crash between child creation and that
# write leaves the child row without origin columns, so
# ``find_latest_gateway_session_for_peer`` can't recover the
# mapping on restart. Inherit them from the parent at creation
# time — but ONLY for compression forks (parent already ended
# with end_reason='compression'). Delegate/subagent children
# are spawned while the parent is still live and must NOT
# inherit routing keys, or peer recovery could repoint gateway
# traffic into a subagent's session.
conn.execute(
"""UPDATE sessions
SET user_id = COALESCE(sessions.user_id,
(SELECT p.user_id FROM sessions p
WHERE p.id = sessions.parent_session_id)),
session_key = COALESCE(sessions.session_key,
(SELECT p.session_key FROM sessions p
WHERE p.id = sessions.parent_session_id)),
chat_id = COALESCE(sessions.chat_id,
(SELECT p.chat_id FROM sessions p
WHERE p.id = sessions.parent_session_id)),
chat_type = COALESCE(sessions.chat_type,
(SELECT p.chat_type FROM sessions p
WHERE p.id = sessions.parent_session_id)),
thread_id = COALESCE(sessions.thread_id,
(SELECT p.thread_id FROM sessions p
WHERE p.id = sessions.parent_session_id)),
display_name = COALESCE(sessions.display_name,
(SELECT p.display_name FROM sessions p
WHERE p.id = sessions.parent_session_id)),
origin_json = COALESCE(sessions.origin_json,
(SELECT p.origin_json FROM sessions p
WHERE p.id = sessions.parent_session_id))
WHERE id = ? AND parent_session_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM sessions p
WHERE p.id = sessions.parent_session_id
AND p.end_reason = 'compression'
)""",
(session_id,),
)
# Session-row creation is transcript-critical: if it fails, the
# first flush of a new session fails and the turn is aborted as
# session_persistence_failed. Ride out long sibling holds.
self._execute_write(_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S)
def create_session(self, session_id: str, source: str, **kwargs) -> str:
"""Create a new session record. Returns the session_id."""
self._insert_session_row(session_id, source, **kwargs)
return session_id
def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
"""Mark a gateway session's expiry-finalization flag in state.db.
Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag
survives even if the JSON index is pruned or lost (#9006).
"""
if not session_id:
return
self._write_sql(
"UPDATE sessions SET expiry_finalized = ? WHERE id = ?",
(1 if finalized else 0, session_id),
)
# ── Gateway routing index (replaces sessions.json, #9006 follow-up) ────
def find_session_by_origin(
self,
*,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[str]:
"""Find the most recent live session_id for a platform + chat origin.
Equivalent of gateway/mirror's sessions.json scan: matches on
source + chat_id (+ thread_id when provided). When ``user_id`` is
provided, exact sender matches are preferred; if multiple distinct
users share the chat and none matches, returns None rather than
contaminating another participant's session.
"""
if not platform or chat_id in (None, ""):
return None
query = """
SELECT id, user_id, started_at FROM sessions
WHERE LOWER(source) = LOWER(?)
AND session_key IS NOT NULL
AND chat_id = ?
AND ended_at IS NULL
"""
params: list = [platform, str(chat_id)]
if thread_id is not None:
query += " AND COALESCE(thread_id, '') = ?"
params.append(str(thread_id))
query += " ORDER BY started_at DESC"
with self._read_ctx() as conn:
rows = [dict(r) for r in conn.execute(query, params).fetchall()]
if not rows:
return None
if user_id:
exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)]
if exact:
return str(exact[0]["id"])
if len(rows) > 1:
return None
elif len(rows) > 1:
distinct_users = {
str(r.get("user_id") or "").strip()
for r in rows
if str(r.get("user_id") or "").strip()
}
if len(distinct_users) > 1:
return None
return str(rows[0]["id"])
# ── Orphaned gateway-session repair (#82616) ──────────────────────────
# A write-path failure (corrupt FTS, crash between routing publication
# and row creation) can leave the live conversation in a session row
# that never received its identity columns. Both queries above require
# those columns, so the row holding the real transcript is invisible to
# recovery: the chat resolves to the last keyed row instead — days older
# — and the conversation time-travels. Hardening the write side cannot
# reach a row that is *already* damaged; these two methods are the
# offline repair path behind ``hermes sessions repair-routing``.
# Widest plausible gap between a keyed predecessor going quiet and its
# unkeyed successor being minted. The reported incident gap was ~60s;
# 15 minutes stays generous without spanning unrelated conversations.
_ORPHAN_ADOPTION_MAX_GAP_S = 900.0
# Children that carry a ``parent_session_id`` but are NOT compression
# continuations: branches, delegate/subagent runs, and tool sessions.
# A marker only disqualifies a child when it points at the parent being
# queried — compression continuations inherit the rotated agent's
# ``model_config`` verbatim (``publish_compression_child`` callers pass
# ``agent._session_init_model_config``), so a delegate subagent's
# continuation carries ``_delegate_from=<the delegate's own parent>``.
# Matching markers by mere presence misclassified those real
# continuations as delegate children (fail-open for orphan reopen,
# fail-closed for adoption). Bind the parent id for both markers.
_NON_CONTINUATION_CHILD_FILTER_SQL = (
" AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'),"
" '$._branched_from'), '') != ?\n"
" AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'),"
" '$._delegate_from'), '') != ?\n"
" AND COALESCE({alias}source, '') != 'tool'\n"
)
def end_session(self, session_id: str, end_reason: str) -> None:
"""Mark a session as ended.
No-ops when the session is already ended. The first end_reason wins:
compression-split sessions must keep their ``end_reason = 'compression'``
record even if a later stale ``end_session()`` call (e.g. from a
desynced CLI session_id after ``/resume`` or ``/branch``) targets them
with a different reason. Use ``reopen_session()`` first if you
intentionally need to re-end a closed session with a new reason.
"""
def _do(conn):
changed = conn.execute(
"UPDATE sessions SET ended_at = ?, end_reason = ? "
"WHERE id = ? AND ended_at IS NULL",
(time.time(), end_reason, session_id),
).rowcount
# Only a boundary this call actually wrote advances the generation:
# the first end_reason wins, so a no-op must not rotate the peer.
if changed:
self._bump_conversation_generation(conn, session_id, end_reason)
self._execute_write(_do)
def reopen_session(self, session_id: str) -> None:
"""Clear ended_at/end_reason so a session can be resumed.
Before clearing a reset boundary, stabilize markerless legacy reset
children that still depend on the parent's mutable end_reason.
"""
def _do(conn):
placeholders = ",".join("?" for _ in _RESET_END_REASONS)
# WHERE shape shared with _RESET_CHILD_SQL's fallback arm via
# _legacy_reset_child_sql so the stamping and the listing
# predicate cannot drift.
conn.execute(
"UPDATE sessions AS child SET model_config = json_set("
"COALESCE(child.model_config, '{}'), '$._reset_from', "
"child.parent_session_id) "
"WHERE child.parent_session_id = ? "
"AND json_extract(COALESCE(child.model_config, '{}'), "
" '$._reset_from') IS NULL "
f"AND {_legacy_reset_child_sql('child', placeholders)}",
(session_id, *_RESET_END_REASONS),
)
conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
(session_id,),
)
self._execute_write(_do)
def promote_to_session_reset(
self, session_id: str, reason: str = "session_reset"
) -> bool:
"""Durably mark a session as ended by an intentional reset boundary.
Promotes *only* live rows (``ended_at IS NULL``) or rows carrying an
accidental end_reason that the recovery query
(``find_latest_gateway_session_for_peer``) treats as recoverable:
``agent_close`` (older gateway cleanup bug) and ``ws_orphan_reap``
(mistaken TUI reaper). Explicit conversation boundaries such as
``compression``, ``session_reset``, ``session_switch``, etc. are
preserved — the first writer wins for those, and a later expiry
finalization must not silently overwrite them.
Plain ``end_session()`` is NOT sufficient for reset boundaries: it
no-ops on an already-ended row, so a row that agent cleanup already
closed as ``agent_close`` would stay recoverable and stale-route
recovery would resurrect the reset session with its full history
(#61220, #61993, #63539).
Keep this promotion set in sync with the recoverable set in
``find_latest_gateway_session_for_peer`` — any reason recovery would
reopen must be promotable here.
``reason`` lets reset paths keep their auditable specific reasons
(``idle``, ``daily``, ``suspended``, ``resume_pending_expired``).
Returns ``True`` when the row was promoted, ``False`` when skipped
(already has a different explicit end_reason, or row not found).
"""
if not session_id:
return False
now = time.time()
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET ended_at = ?, end_reason = ? "
"WHERE id = ? AND (ended_at IS NULL "
f"OR end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))",
(now, reason, session_id),
)
# /new and the policy auto-resets promote rather than end_session,
# so the generation has to advance here too — in the same
# transaction as the boundary, and only when one was written.
if cursor.rowcount:
self._bump_conversation_generation(conn, session_id, reason)
return cursor.rowcount
try:
rows = self._execute_write(_do)
return bool(rows)
except Exception:
return False
def update_session_cwd(
self,
session_id: str,
cwd: str,
git_branch: Optional[str] = None,
git_repo_root: Optional[str] = None,
replace_git_meta: bool = False,
) -> Optional[int]:
"""Persist the authoritative cwd and claim a Git metadata generation.
``git_branch`` records the git branch checked out in ``cwd`` at the time
the session started/resumed. The sidebar groups main-checkout sessions
by this so feature-branch work doesn't pile under a single "main" row
(the main checkout's *current* branch is transient and would
misattribute past sessions).
``git_repo_root`` records the git repo this cwd belongs to — the
authoritative project key. Resolving it here, at the lowest level, means
every surface reads the same membership instead of re-probing git in the
GUI over a partial page. Each field is only written when non-empty so a
probe failure never clobbers a previously-captured value.
``replace_git_meta`` inverts that non-empty rule: a deliberate workspace
MOVE (re-homing a session into another project) must overwrite the old
repo identity even when the new cwd resolves to none — keeping the stale
root would leave the session grouped under the project it just left.
Every call increments ``git_metadata_generation`` in the same write
transaction. Async Git probes must publish through
:meth:`publish_session_git_metadata` with the returned generation, so
an older worker cannot overwrite a newer cwd claim even after an
A -> B -> A transition or from another process sharing this database.
Metadata from a different cwd is cleared atomically with the move.
"""
if not session_id or not cwd:
return None
branch = (git_branch or "").strip()
repo_root = (git_repo_root or "").strip()
def _do(conn):
current = conn.execute(
"SELECT cwd FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if current is None:
return None
current_cwd = current[0]
sets = [
"cwd = ?",
"git_metadata_generation = COALESCE(git_metadata_generation, 0) + 1",
]
params: List[Any] = [cwd]
if current_cwd != cwd or replace_git_meta:
sets.extend(("git_branch = ?", "git_repo_root = ?"))
params.extend((branch or None, repo_root or None))
elif branch:
sets.append("git_branch = ?")
params.append(branch)
if repo_root and current_cwd == cwd and not replace_git_meta:
sets.append("git_repo_root = ?")
params.append(repo_root)
params.append(session_id)
conn.execute(
f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params
)
row = conn.execute(
"SELECT git_metadata_generation FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return None
value = row[0]
return int(value)
return self._execute_write(_do)
def publish_session_git_metadata(
self,
session_id: str,
cwd: str,
generation: int,
git_branch: Optional[str] = None,
git_repo_root: Optional[str] = None,
) -> bool:
"""Publish async Git enrichment only while its cwd claim is current."""
if (
not session_id
or not cwd
or isinstance(generation, bool)
or not isinstance(generation, int)
or generation < 1
):
return False
branch = (git_branch or "").strip()
repo_root = (git_repo_root or "").strip()
if not branch and not repo_root:
return False
sets: List[str] = []
params: List[Any] = []
if branch:
sets.append("git_branch = ?")
params.append(branch)
if repo_root:
sets.append("git_repo_root = ?")
params.append(repo_root)
params.extend((session_id, cwd, generation))
return self._write_rowcount(
f"UPDATE sessions SET {', '.join(sets)} "
"WHERE id = ? AND cwd = ? "
"AND git_metadata_generation = ?",
params,
) == 1
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
"""Persist resolved git repo roots for cwds that don't have one yet.
Backfills history so projects light up for sessions created before the
column existed, without clobbering an already-recorded root. Only
non-empty roots are written (a non-git cwd stays NULL).
"""
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
if not pairs:
return
def _do(conn):
for root, cwd in pairs:
conn.execute(
"UPDATE sessions SET git_repo_root = ? "
"WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
(root, cwd),
)
self._execute_write(_do)
# ──────────────────────────────────────────────────────────────────────
# Compression locks
# ──────────────────────────────────────────────────────────────────────
# Atomic per-session locks that prevent two compression paths from
# racing on the same session_id and producing orphan child sessions.
#
# The race: ``conversation_compression.py`` rotates ``agent.session_id``
# as a side effect of a successful compression (end old session, create
# new). That mutation is local to the AIAgent instance — but ``state.db``
# is shared across all instances. Two AIAgents that share the same
# ``session_id`` at the moment they both decide to compress (most
# commonly the parent turn's agent + a background-review fork started
# right after the turn ended) each end the parent and create their own
# NEW session, parented to the same old id. The gateway SessionEntry
# only catches one rotation; the other child silently accumulates
# writes — Damien's "parent → two orphan children" repro shape.
#
# The lock is keyed by ``session_id`` and is held for the duration of
# the compress() call plus the rotation. ``holder`` identifies the
# current owner (pid:tid:nonce) for diagnostics; the lock is recovered
# via ``expires_at`` if the holder process crashed without releasing.
def touch_session_activity(
self,
session_id: str,
ts: Optional[float] = None,
*,
description: Optional[str] = None,
provenance: Optional[ActivityProvenance] = None,
) -> None:
"""Stamp durable mid-turn session activity (observation-only).
Called (rate-limited) from ``AIAgent._touch_activity`` so gateway/CLI
surfaces and stall consumers observe API/tool/compaction activity
even when no new message row has been written yet (#72016 / #72039).
Never moves ``last_activity_at`` backwards. When the timestamp
advances, bounded ``last_activity_description`` /
``last_activity_provenance`` are written with it. No-ops when
``session_id`` is empty or the row does not exist.
"""
if not session_id:
return
from agent.session_activity import (
bound_activity_description,
normalize_activity_provenance,
)
when = float(ts if ts is not None else time.time())
desc = bound_activity_description(description)
prov = normalize_activity_provenance(provenance).value
# Observation-only write: never let it ride the full routine
# write-patience budget (#76354 review S1). Under contention a
# heartbeat that waits ~20s would delay the response-critical path
# it is merely observing; give up after a sub-second budget instead
# (the next due window retries naturally).
self._write_sql(
"UPDATE sessions SET "
"last_activity_at = ?, "
"last_activity_description = ?, "
"last_activity_provenance = ? "
"WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)",
(when, desc, prov, session_id, when),
patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
)
def clear_session_activity_labels(self, session_id: str) -> None:
"""Clear mid-turn activity labels after a turn ends.
Keeps ``last_activity_at`` intact so idle / watchdog clocks stay
continuous. Description and provenance are observation labels for
*what was happening at* that timestamp during an active turn; once
the turn is idle they must not keep advertising "compressing" /
"executing tool" (#72039).
Response-critical-path contract (#76354 review S1): runs in the
turn's ``finally``; a no-op clear (labels already empty) skips the
write transaction entirely, and a real clear uses the same short
sub-second busy budget as :meth:`touch_session_activity` instead of
the full routine write patience.
"""
if not session_id:
return
from agent.session_activity import ActivityProvenance
# No-op fast path: skip the transaction when there is nothing to
# clear. Read-only, no write lock.
try:
row = self._read_one(
"SELECT last_activity_description, last_activity_provenance "
"FROM sessions WHERE id = ?",
(session_id,),
)
except sqlite3.Error:
row = None
if row is not None:
desc = row[0]
prov = row[1]
if not desc and (
not prov or prov == ActivityProvenance.UNKNOWN.value
):
return
self._write_sql(
"UPDATE sessions SET "
"last_activity_description = ?, "
"last_activity_provenance = ? "
"WHERE id = ?",
("", ActivityProvenance.UNKNOWN.value, session_id),
patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
)
def get_session_activity(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Return the durable activity snapshot for *session_id*, or None."""
if not session_id:
return None
row = self.get_session(session_id)
if not row:
return None
from agent.session_activity import build_activity_snapshot
return build_activity_snapshot(
last_activity_at=row.get("last_activity_at"),
last_activity_description=row.get("last_activity_description"),
last_activity_provenance=row.get("last_activity_provenance"),
)
def update_session_meta(
self,
session_id: str,
model_config_json: str,
model: Optional[str] = None,
) -> None:
"""Update model_config and optionally model for an existing session.
Uses COALESCE so that passing model=None leaves the stored model
column unchanged. Routes through _execute_write for the standard
BEGIN IMMEDIATE + jitter-retry + lock guarantee.
"""
# Barrier against queued token deltas — see update_session_model.
self.flush_token_counts()
self._write_sql(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(model_config_json, model, session_id),
)
def update_system_prompt(
self, session_id: str, system_prompt: Optional[str]
) -> None:
"""Store the full assembled system prompt snapshot."""
def _do(conn):
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
conn.execute(
"UPDATE sessions "
"SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?",
(system_prompt_hash, session_id),
)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
def update_session_tool_names(
self, session_id: str, tool_names: Optional[List[str]]
) -> None:
"""Persist the session's resolved ``tools[]`` name order (JSON array).
Read back by ``tools.mcp_tool.restore_agent_tool_prefix`` when a fresh
``AIAgent`` is rebuilt for an existing session (gateway agent-cache
eviction) so a flipped ``check_fn`` verdict can't fork the cached tool
prefix. ``None`` clears the pin.
"""
payload = json.dumps(list(tool_names)) if tool_names is not None else None
self._write_sql(
"UPDATE sessions SET tool_names = ? WHERE id = ?",
(payload, session_id),
)
def update_session_model(
self, session_id: str, model: str, provider: Optional[str] = None
) -> None:
"""Update the model for a session after a mid-session switch.
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
(only filling in NULL), this unconditionally sets the model column
so that the dashboard reflects the user's latest /model choice.
Also nulls ``system_prompt`` so stale ``Model:`` / ``Provider:``
footer metadata is rebuilt on the next turn. A successful /model
switch explicitly replaces any confirmed Browser runtime lock while
preserving unrelated lineage markers in ``model_config``.
When *provider* is given, it is merged into ``model_config``
alongside the model (``$.model`` / ``$.provider``) so a later
resume recombines the persisted model with the provider that
actually serves it instead of the config.yaml primary provider
(#79536). Callers without provider knowledge leave any stored
provider untouched.
"""
# This write bypasses the token queue, so deltas enqueued before the
# switch must land first: a still-queued first delta carries the
# pre-switch route, and applying it after this UPDATE would trip the
# first_accounted_route overwrite in update_token_counts (row sees
# api_call_count == 0 + a route mismatch) and resurrect the old
# model/provider. Flushing here restores the pre-queue ordering.
self.flush_token_counts()
def _do(conn):
# Use the shared merge discipline so lineage markers like
# _branched_from / _delegate_from survive. browser_model_lock
# is deleted via a None patch value (same semantics as the
# old json_remove).
patch: Dict[str, Any] = {"browser_model_lock": None}
if model:
patch["model"] = model
if provider:
patch["provider"] = provider
merged = self._merge_model_config_json(conn, session_id, patch)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
conn.execute(
"UPDATE sessions SET "
"model = ?, model_config = ?, "
"system_prompt = NULL, system_prompt_hash = NULL "
"WHERE id = ?",
(model, merged, session_id),
)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
def _merge_model_config_json(
self,
conn,
session_id: str,
patch: Dict[str, Any],
*,
on_missing: str = "skip",
):
"""SELECT + tolerant-parse + merge ``patch`` into a session's model_config.
Shared by every model_config writer (``update_session_runtime_lock``,
``set_session_yolo``, ``archive_and_compact``,
``patch_session_model_config``) so the merge discipline that keeps
lineage markers like ``_branched_from`` / ``_delegate_from`` alive
lives in exactly one place. A ``None`` patch value deletes that key.
Must run inside an open write transaction (callers own the UPDATE).
Returns the serialized merged JSON — ``None`` when the merged dict is
empty (matching ``create_session``'s NULL convention) — or the
``_MODEL_CONFIG_ROW_MISSING`` sentinel when the row doesn't exist and
``on_missing == "skip"``; ``on_missing == "raise"`` raises ValueError.
"""
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
if on_missing == "raise":
raise ValueError(f"Session not found: {session_id}")
return _MODEL_CONFIG_ROW_MISSING
raw = row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except (json.JSONDecodeError, TypeError):
config = {}
elif isinstance(raw, dict):
config = dict(raw)
for key, value in patch.items():
if value is None:
config.pop(key, None)
else:
config[key] = value
return json.dumps(config) if config else None
def patch_session_model_config(
self, session_id: str, patch: Dict[str, Any]
) -> None:
"""Merge ``patch`` into a session's model_config JSON atomically.
A ``None`` patch value removes that key. No-op when the session row
doesn't exist or the patch is empty. This is the standalone setter for
callers that need to update model_config *without* rewriting the
transcript (the transcript-coupled path is ``archive_and_compact``'s
``model_config_patch``, which shares the same merge helper).
"""
if not session_id or not patch:
return
def _do(conn):
merged = self._merge_model_config_json(conn, session_id, patch)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(merged, session_id),
)
self._execute_write(_do)
def get_session_model_config_value(
self, session_id: str, key: str, default: Any = None
) -> Any:
"""Read one key out of a session's model_config JSON (tolerant parse)."""
session = self.get_session(session_id) or {}
raw = session.get("model_config")
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except (json.JSONDecodeError, TypeError):
config = {}
elif isinstance(raw, dict):
config = raw
return config.get(key, default)
def update_session_runtime_lock(
self,
session_id: str,
*,
model: Optional[str] = None,
provider: Optional[str] = None,
model_options: Optional[Dict[str, Any]] = None,
route_source: Optional[str] = None,
confirmed: bool = False,
) -> None:
"""Persist a Browser / API client runtime lock without clobbering lineage markers.
Merges ``browser_model_lock`` into the existing ``model_config`` JSON so
``_branched_from`` / ``_delegate_from`` survive. Nulls ``system_prompt``
so cached ``Model:`` / ``Provider:`` footers cannot lie after a switch.
"""
lock = {
"provider": provider or "",
"model": model or "",
"model_options": model_options or {},
"route_source": route_source or "",
"confirmed": bool(confirmed),
"updated_at": time.time(),
}
def _do(conn):
merged = self._merge_model_config_json(
conn, session_id, {"browser_model_lock": lock}
)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
conn.execute(
"""UPDATE sessions SET
model_config = ?,
model = COALESCE(?, model),
system_prompt = NULL,
system_prompt_hash = NULL
WHERE id = ?""",
(merged, model, session_id),
)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
def set_session_yolo(self, session_id: str, enabled: bool) -> None:
"""Persist the per-session YOLO bypass flag into ``model_config``.
Merges ``yolo_mode`` into the existing ``model_config`` JSON (same
merge discipline as ``update_session_runtime_lock`` so lineage
markers like ``_branched_from`` / ``_delegate_from`` survive). The
CLI resume paths read this flag back so a ``/yolo ON`` toggle — or a
``--yolo`` launch — survives ``hermes --resume`` into a fresh
process. No-op when the session row doesn't exist yet; the
creation-time ``model_config`` carries the flag for ``--yolo``
launches.
"""
if not session_id:
return
def _do(conn):
merged = self._merge_model_config_json(
conn, session_id, {"yolo_mode": bool(enabled)}
)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(merged, session_id),
)
self._execute_write(_do)
@staticmethod
def session_yolo_enabled(session_meta: Optional[Dict[str, Any]]) -> bool:
"""Read the persisted YOLO flag off a session row dict.
Accepts the dict returned by ``get_session`` (``model_config`` is a
JSON string) or an already-parsed dict. Returns False on any parse
failure — resume must never enable the bypass by accident.
"""
raw = (session_meta or {}).get("model_config")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return False
if not isinstance(raw, dict):
return False
return bool(raw.get("yolo_mode"))
# ── Async token accounting ──
# update_token_counts() runs a sessions UPDATE (plus a per-model usage
# upsert) inside BEGIN IMMEDIATE; against a cold multi-GB state.db one
# call can stall the turn thread for tens to hundreds of ms, and the
# tool loop pays it after EVERY API call (measured p50 3.3ms / p95 70ms
# per call in production). queue_token_counts() reduces the critical
# path to a deque append: a dedicated single-writer thread applies
# deltas in enqueue order, coalescing consecutive same-route deltas
# into one UPDATE when a backlog forms. Readers that need exact
# mid-turn totals (get_session and friends) call flush_token_counts()
# first — a plain attribute check when nothing is queued.
# Delta fields summed when coalescing. Route fields must be equal for
# two deltas to merge: model/billing_* feed COALESCE backfill and the
# per-model usage attribution key, and cost_status/cost_source are
# last-non-None-wins — equality makes the merged UPDATE byte-for-byte
# equivalent to applying the deltas sequentially.
_TOKEN_DELTA_SUM_FIELDS = (
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "api_call_count",
)
_TOKEN_DELTA_COST_FIELDS = ("estimated_cost_usd", "actual_cost_usd")
_TOKEN_DELTA_ROUTE_FIELDS = (
"model", "cost_status", "cost_source", "pricing_version",
"billing_provider", "billing_base_url", "billing_mode",
)
def ensure_session(
self,
session_id: str,
source: str = "unknown",
model: str = None,
**kwargs,
) -> str:
"""Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
self._insert_session_row(session_id, source, model=model, **kwargs)
return session_id
# ── Cross-backend heartbeat API (#94895) ───────────────────────────
# Each serve / tui_gateway process registers a heartbeat row at startup
# and refreshes ``last_heartbeat`` periodically. The startup orphan
# sweep reads these rows to avoid reaping sessions owned by another
# still-live backend that just happens to be idle. Backends remove
# their own row on graceful shutdown; a row that survives a crash is
# reclaimed by the staleness sweep once ``last_heartbeat`` ages out.
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get a session by ID."""
# Cost/usage readers (/status, /usage, gateway endpoints) reach the
# row through here; drain queued token deltas so they see exact
# totals. No-op attribute check when nothing is queued.
self.flush_token_counts()
row = self._read_one(
"SELECT s.*, "
"COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
"FROM sessions s "
"LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
"WHERE s.id = ?",
(session_id,),
)
return self._session_row_dict(row) if row else None
def get_dominant_session_model_route(
self, session_id: str
) -> Optional[Dict[str, Any]]:
"""Return the main-loop model route that served most API calls.
``sessions`` is a legacy aggregate row and can hold model/provider fields
written by different route changes. ``session_model_usage`` keeps the
coherent per-call tuple, so persisted status and billing reads should use
its dominant main-loop route when one is available.
"""
self.flush_token_counts()
row = self._read_one(
"""SELECT model, billing_provider, billing_base_url, billing_mode,
api_call_count
FROM session_model_usage
WHERE session_id = ?
AND task = ''
AND model <> 'unknown'
AND billing_provider <> ''
ORDER BY api_call_count DESC,
(input_tokens + output_tokens + cache_read_tokens +
cache_write_tokens + reasoning_tokens) DESC,
last_seen DESC
LIMIT 1""",
(session_id,),
)
return dict(row) if row else None
def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
"""Resolve an exact or uniquely prefixed session ID to the full ID.
Returns the exact ID when it exists. Otherwise treats the input as a
prefix and returns the single matching session ID if the prefix is
unambiguous. Returns None for no matches or ambiguous prefixes.
"""
exact = self.get_session(session_id_or_prefix)
if exact:
return exact["id"]
escaped = _escape_like(session_id_or_prefix)
matches = [row["id"] for row in self._read_all(
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
(f"{escaped}%",),
)]
if len(matches) == 1:
return matches[0]
return None
# Maximum length for session titles
MAX_TITLE_LENGTH = 100
# Title provenance, lowest to highest authority. An auto-titling write may
# only replace a title of strictly lower authority, so the instant
# ``derived`` title upgrades to the model's ``llm`` title exactly once and
# nothing the agent generates can ever clobber a name the user typed.
TITLE_SOURCE_DERIVED = "derived"
TITLE_SOURCE_LLM = "llm"
TITLE_SOURCE_USER = "user"
_TITLE_SOURCE_RANK = {
TITLE_SOURCE_DERIVED: 0,
TITLE_SOURCE_LLM: 1,
TITLE_SOURCE_USER: 2,
}
# Bot Mode's forever-chat registry: the session titled exactly this, on a
# bot's profile, IS the bot's canonical chat — resolved by exact-title
# lookup on every open (no session-id pointer exists). The title is the
# identity, which is why _set_session_title refuses renames of a hidden
# row holding it (#92473).
CANONICAL_BOT_CHAT_TITLE = "Bot Chat"
def backfill_null_session_profiles(self, profile_name: str) -> int:
"""One-shot owner backfill for legacy pre-ownership session rows.
Sessions created before the durable-ownership work (#95407 lineage)
carry ``profile_name = NULL``. On single-backend installs that was
harmless, but once a Desktop registers a second connection the
fail-closed owner ladder (which is correct for new sessions) can no
longer route those rows anywhere — every pre-campaign session becomes
unresumable after upgrade (#94724, field report).
This store belongs to exactly one profile — the profile whose
``state.db`` this is — so stamping its own name onto rows that never
recorded one is a single-match backfill, not a guess. Rules mirror the
``create_session`` COALESCE contract:
* only ``NULL``/empty ``profile_name`` rows are touched — a non-NULL
owner is NEVER overwritten;
* idempotent and one-shot-per-row: a second run matches zero rows.
Returns the number of rows stamped (0 when nothing was legacy).
"""
stamp = (profile_name or "").strip()
if not stamp:
return 0
return int(self._write_rowcount(
"""UPDATE sessions
SET profile_name = ?
WHERE profile_name IS NULL OR TRIM(profile_name) = ''""",
(stamp,),
) or 0)
def _set_lineage_column(self, column: str, session_id: str, value: Any) -> bool:
"""Set one ``sessions`` column across a whole compression lineage.
Walks ancestors and descendants joined by ``end_reason='compression'``
so the root and every continuation flip as a unit — Desktop projects
compression roots forward to their latest tip, and updating only the
displayed tip would let the untouched root resurrect it on refresh.
Returns True when at least one row changed.
"""
return self._write_rowcount(
f"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET {column} = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, value),
) > 0
def set_session_archived(self, session_id: str, archived: bool) -> bool:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages — this is a soft hide, not a delete. For compression
chains, archive the whole logical conversation. Desktop lists compression
roots projected forward to their latest continuation; updating only the
displayed tip lets the still-unarchived root resurrect it on refresh.
Returns True when at least one row was updated.
"""
return self._set_lineage_column('archived', session_id, 1 if archived else 0)
# Accidental end reasons that recovery treats as resumable. Single source
# of truth: hermes_state_common._RECOVERABLE_END_REASONS, interpolated
# into find_latest_gateway_session_for_peer / promote_to_session_reset
# SQL — literals cannot drift (docs/session-lifecycle.md "recoverable
# accidental reasons").
RECOVERABLE_END_REASONS = _RECOVERABLE_END_REASONS
def unarchive_recoverable_session(self, session_id: str) -> bool:
"""Un-archive a session that was archived by a recoverable accident.
Registry-style lookups (Bot Mode's canonical "Bot Chat") use this to
resurrect a row the ws-orphan reaper (``ws_orphan_reap``) or older
agent cleanup (``agent_close``) archived: those ends are accidents,
not user intent, so the identity-scoped canonical chat must survive
them (#92687). Sessions archived with no end_reason or an explicit
boundary reason (user archived deliberately, ``session_reset``, …)
are left untouched — returns ``False`` for those, ``True`` only when
the row was archived for a recoverable reason and is now un-archived
(whole compression lineage, via :meth:`set_session_archived`).
"""
if not session_id:
return False
try:
row = self.get_session(session_id)
except Exception:
return False
if not row or not row.get("archived"):
return False
# A compressed lineage's registry row keeps end_reason='compression';
# the accidental stamp lives on the live TIP. Judge recoverability at
# the tip (== the row itself when uncompressed).
tip = row
try:
tip_id = self.get_compression_tip(session_id) or session_id
if tip_id != session_id:
tip = self.get_session(tip_id) or row
except Exception:
tip_id = session_id
if (tip.get("end_reason") or "") not in self.RECOVERABLE_END_REASONS:
return False
if not self.set_session_archived(session_id, False):
return False
# Clear the accidental end stamp: the session is live again, and a
# surviving ws_orphan_reap/agent_close reason would make a LATER
# deliberate archive (which never writes end_reason) auto-resurrect
# on the next lookup — permanently overriding user intent.
def _clear_end(conn):
conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
(tip["id"],),
)
return 1
self._execute_write(_clear_end)
return True
def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
"""Pin or unpin a session (and its whole compression lineage).
``pinned`` is a durable "keep" flag: pinned sessions are exempt from
the ``sessions.auto_archive`` stale sweep (see
:meth:`archive_stale_sessions`). Desktop is the current writer — its
sidebar pins mirror here so a backend/other-surface sweep honours
them. Like :meth:`set_session_archived` the whole compression chain is
flipped as a unit, so pinning the surfaced tip protects the root (and
vice-versa) no matter which id the caller holds. Returns True when at
least one row changed.
"""
return self._set_lineage_column('pinned', session_id, 1 if pinned else 0)
def set_session_hidden(self, session_id: str, hidden: bool) -> bool:
"""Hide or unhide a session (and its whole compression lineage).
``hidden`` is a generic "don't show in the global Sessions sidebar"
flag: a hidden session is dropped from the default
:meth:`list_sessions_rich` listing (which omits ``include_hidden``) but
stays fully resumable by the surface that owns it — useful for plugins
that manage their own sessions (e.g. kanban) and don't want them
cluttering the shared recents list. Like :meth:`set_session_archived`
/ :meth:`set_session_pinned` the whole compression chain is flipped as
a unit, so hiding the surfaced tip hides the root (and vice-versa) no
matter which id the caller holds. Returns True when at least one row
changed.
"""
return self._set_lineage_column('hidden', session_id, 1 if hidden else 0)
def set_session_read(self, session_id: str, read: bool = True) -> bool:
"""Mark a session read or unread (and its whole compression lineage).
Read state is a watermark, not a flag: ``last_read_at`` records when
the conversation was last read, and it counts as unread when activity
postdates that watermark (the derived ``unread`` key on
:meth:`list_sessions_rich` rows). New messages therefore flip a read
conversation back to unread without any write on the message path.
Three states:
* NULL — never tracked (every pre-feature row): treated as read, so
shipping the column doesn't badge a user's entire history at once.
* 0 — explicitly marked unread: any activity postdates it.
* timestamp — read up to that moment.
Like :meth:`set_session_archived` / :meth:`set_session_pinned`, the
whole compression chain is stamped as a unit, so reading the surfaced
tip clears the root (and vice-versa) no matter which id the caller
holds. Returns True when at least one row changed.
"""
return self._set_lineage_column('last_read_at', session_id, time.time() if read else 0.0)
@staticmethod
def session_unread(session_row: Dict[str, Any]) -> bool:
"""Derive unread from a session row's watermark and activity.
Shared by ``list_sessions_rich`` and any future surface that holds a
row (or projected row) with ``last_read_at`` and ``last_active``.
NULL watermark = never tracked = read.
"""
last_read = session_row.get("last_read_at")
if last_read is None:
return False
last_active = session_row.get("last_active") or session_row.get("started_at")
return float(last_active or 0) > float(last_read)
# Columns excluded from compact_rows projections: only the payload-heavy
# blob no list consumer renders. Everything else — including gateway
# routing fields and desktop sidebar fields like git_branch — stays, and
# the projection is derived from SCHEMA_SQL so columns added later via
# declarative reconciliation are included automatically instead of
# silently dropping out of list rows.
_SESSION_COMPACT_EXCLUDED = frozenset(
{"system_prompt", "system_prompt_hash", "git_metadata_generation"}
)
_session_compact_cols_sql: Optional[str] = None
def list_sessions_rich(
self,
source: str = None,
sources: List[str] = None,
exclude_sources: List[str] = None,
cwd_prefix: str = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
min_message_count: int = 0,
project_compression_tips: bool = True,
order_by_last_active: bool = False,
include_archived: bool = False,
archived_only: bool = False,
id_query: str = None,
search_query: str = None,
compact_rows: bool = False,
include_pinned: bool = False,
session_key: str = None,
include_hidden: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last_active, in one query.
``last_active`` = freshest of the activity heartbeat and the latest
message timestamp, else ``started_at``. Implementation-detail children
(subagent runs, compression continuations) are excluded unless
``include_children``; branch/reset children stay listable.
``project_compression_tips`` (default) surfaces each compression chain
as ONE entry showing the live tip's id/message_count/title/last_active;
``False`` returns raw root rows (admin/debug UIs).
``order_by_last_active`` sorts by the chain TIP's activity via a
recursive CTE, so LIMIT/OFFSET stay cheap and a recently continued old
conversation lands in the right slot. ``search_query`` (that path
only) substring-matches title/id across the forward chain, plus a
punctuation-stripped variant (``an94`` finds ``AN-94``).
``compact_rows`` omits the ``system_prompt`` blob from the SELECT so
SQLite never copies tens of KB per row out of the B-tree page.
``include_pinned`` back-fills pinned conversations the page window left
out (a pin means "always reachable"; the desktop sidebar would
otherwise render an empty Pinned section) — they still obey the
source/archived/min_message_count filters. ``session_key`` restricts to
one gateway conversation scope.
"""
# Rows carry token/cost totals — drain queued deltas first so
# listings (sidebar, /resume, dashboards) show exact counters.
self.flush_token_counts()
where_clauses = []
params = []
if not include_children:
# Show roots and user-visible branch/reset sessions, while still
# hiding sub-agent runs and compression continuations. All four
# carry parent_session_id, so the shared predicate classifies the
# edge from stable markers plus legacy-compatible parent metadata.
#
# Branch sessions are identified two ways, OR'd for robustness:
# 1. A stable ``_branched_from`` marker in model_config, written
# by /branch at creation time. This survives the parent being
# reopened and re-ended with a different end_reason (e.g.
# tui_shutdown overwriting 'branched'), which otherwise hides
# the branch — see issue #20856.
# 2. The legacy heuristic (parent ended with 'branched' before the
# child started), covering branch sessions created before the
# marker existed.
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
include_sources = [source] if source else list(sources or [])
if include_sources:
placeholders = ",".join("?" for _ in include_sources)
where_clauses.append(f"s.source IN ({placeholders})")
params.extend(include_sources)
if session_key:
where_clauses.append("s.session_key = ?")
params.append(session_key)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
if not include_hidden:
where_clauses.append("s.hidden = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
# Snapshot the filter params before the query builders below extend
# them with LIMIT/OFFSET — the pinned back-fill reuses the same WHERE.
base_where_params = list(params)
prompt_select = (
"" if compact_rows
else ", COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved"
)
prompt_join = (
"" if compact_rows
else "LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash"
)
# Optional session-id filter, pushed into SQL so callers (Desktop
# session-id search) don't have to fetch every row and filter in
# Python. ``id_query`` is matched as a case-insensitive substring
# against each surfaced row's id AND every id in its forward
# compression chain — so searching a compression *root* id or a *tip*
# id both resolve to the same projected conversation. Only used in the
# order_by_last_active path (which builds the chain CTE); other callers
# pass id_query=None.
id_needle = (id_query or "").strip().lower()
search_needle = (search_query or "").strip().lower()
if order_by_last_active:
# Compute effective_last_active by walking each surfaced session's
# compression-continuation chain forward in SQL and taking the MAX
# timestamp across the chain. This lets us ORDER BY + LIMIT at SQL
# level instead of fetching every row and sorting in Python, while
# still surfacing old compression roots whose live tip is fresh.
#
# The CTE seeds from rows the outer WHERE admits (roots +
# user-visible branch/reset children), then recursively joins through
# compression-continuation edges. Do NOT require
# child.started_at >= parent.ended_at here: real desktop/gateway
# races can insert the continuation row before the parent's
# ended_at is written, while stale websocket siblings may satisfy
# the timestamp test and hijack resume/list projection.
outer_where = where_sql
id_params: List[Any] = []
filter_clauses: List[str] = []
def _like_pattern(needle: str) -> str:
return f"%{_escape_like(needle)}%"
if id_needle:
# Admit a surfaced row if its own id or any id in its forward
# compression chain matches the needle. LIKE with a leading
# wildcard can't use an index, but the chain membership and
# the small result set keep this bounded — far cheaper than
# fetching every session and scanning in Python.
filter_clauses.append(
"EXISTS (SELECT 1 FROM chain cq"
" WHERE cq.root_id = s.id"
" AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
)
id_params.append(_like_pattern(id_needle))
if search_needle:
# Same chain-membership trick as id_query, but matching either
# the title or the id of any session in the chain. The compact
# (punctuation-stripped) variant lets `an94` match `AN-94`.
compact_needle = re.sub(r"[\W_]+", "", search_needle)
compact_sql = (
"REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE({0}, '')),"
" '-', ''), '_', ''), '.', ''), ' ', '')"
)
search_clause = (
"EXISTS (SELECT 1 FROM chain cq"
" JOIN sessions cs ON cs.id = cq.cur_id"
" WHERE cq.root_id = s.id"
" AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\\'"
" OR LOWER(cq.cur_id) LIKE ? ESCAPE '\\'"
)
id_params.extend([_like_pattern(search_needle)] * 2)
if compact_needle:
search_clause += (
f" OR {compact_sql.format('cs.title')} LIKE ? ESCAPE '\\'"
)
id_params.append(_like_pattern(compact_needle))
filter_clauses.append(search_clause + "))")
if filter_clauses:
combined = " AND ".join(filter_clauses)
outer_where = (
f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}"
)
_sel = self._compact_session_cols() if compact_rows else "s.*"
query = f"""
WITH RECURSIVE chain(root_id, cur_id) AS (
SELECT s.id, s.id FROM sessions s {where_sql}
UNION ALL
SELECT c.root_id, child.id
FROM chain c
JOIN sessions parent ON parent.id = c.cur_id
JOIN sessions child ON child.parent_session_id = c.cur_id
WHERE parent.end_reason = 'compression'
AND json_extract(COALESCE(child.model_config, '{{}}'), '$._branched_from') IS NULL
AND json_extract(COALESCE(child.model_config, '{{}}'), '$._delegate_from') IS NULL
AND COALESCE(child.source, '') != 'tool'
),
chain_max AS (
SELECT
root_id,
MAX({_sql_session_last_active_by_id("cur_id")}) AS effective_last_active
FROM chain
GROUP BY root_id
)
SELECT {_sel}{prompt_select},
COALESCE(
(SELECT {_PREVIEW_RAW_SELECT}
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
AND {_PREVIEW_ELIGIBLE_SQL}
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
{_sql_session_last_active("s")} AS last_active,
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
FROM sessions s
LEFT JOIN chain_max cm ON cm.root_id = s.id
{prompt_join}
{outer_where}
ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
LIMIT ? OFFSET ?
"""
# WHERE params apply twice (CTE seed + outer select); the id filter
# only applies to the outer select.
params = params + params + id_params + [limit, offset]
else:
_sel = self._compact_session_cols() if compact_rows else "s.*"
query = f"""
SELECT {_sel}{prompt_select},
COALESCE(
(SELECT {_PREVIEW_RAW_SELECT}
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
AND {_PREVIEW_ELIGIBLE_SQL}
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
{_sql_session_last_active("s")} AS last_active
FROM sessions s
{prompt_join}
{where_sql}
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
rows = self._read_all(query, params)
sessions = []
for row in rows:
s = self._session_row_dict(row)
s["preview"] = _shape_preview(s.pop("_preview_raw", ""))
# Drop the internal ordering column so callers see a clean dict.
s.pop("_effective_last_active", None)
sessions.append(s)
# Back-fill pinned conversations the page missed. A pin outlives
# recency, so this runs BEFORE compression projection below — a
# back-filled root then projects to its live tip exactly like a row
# that had made the page on its own. One extra query, bounded by the
# number of pins (a handful), never N+1 per pin.
if include_pinned:
seen_ids = {s["id"] for s in sessions}
pinned_where = (
f"{where_sql} AND s.pinned = 1" if where_sql else "WHERE s.pinned = 1"
)
_sel = self._compact_session_cols() if compact_rows else "s.*"
pinned_query = f"""
SELECT {_sel}{prompt_select},
COALESCE(
(SELECT {_PREVIEW_RAW_SELECT}
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
AND {_PREVIEW_ELIGIBLE_SQL}
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
{prompt_join}
{pinned_where}
ORDER BY s.started_at DESC
"""
pinned_rows = self._read_all(pinned_query, base_where_params)
for row in pinned_rows:
s = self._session_row_dict(row)
if s["id"] in seen_ids:
continue
s["preview"] = _shape_preview(s.pop("_preview_raw", ""))
seen_ids.add(s["id"])
sessions.append(s)
# Project compression roots forward to their tips. Each row whose
# end_reason is 'compression' has a continuation child; replace the
# surfaced fields (id, message_count, title, last_active, ended_at,
# end_reason, preview) with the tip's values so the list entry acts
# as the live conversation. Keep the root's started_at to preserve
# chronological ordering by original conversation start.
if project_compression_tips and not include_children:
# get_compression_tip() walks each root's chain individually (it's
# a per-session graph walk, not batchable in one query), but the
# tip *row* fetch afterward was previously one _get_session_rich_row()
# call per compression root. Batch that half instead: resolve
# every tip id first, then fetch all tip rows in a single query.
tip_ids_by_root: Dict[str, str] = {}
chain_by_root: Dict[str, List[str]] = {}
for s in sessions:
if s.get("end_reason") != "compression":
continue
chain = self.get_compression_chain(s["id"])
tip_id = chain[-1] if chain else s["id"]
if tip_id != s["id"]:
tip_ids_by_root[s["id"]] = tip_id
chain_by_root[s["id"]] = chain
tip_rows = (
self._get_session_rich_rows_batch(
set(tip_ids_by_root.values()), compact_rows=compact_rows
)
if tip_ids_by_root
else {}
)
projected = []
for s in sessions:
tip_id = tip_ids_by_root.get(s["id"])
tip_row = tip_rows.get(tip_id) if tip_id else None
if not tip_row:
projected.append(s)
continue
# Preserve the root's started_at for stable sort order, but
# surface the tip's identity and activity data.
merged = dict(s)
for key in (
"id", "ended_at", "end_reason", "message_count",
"tool_call_count", "title", "last_active", "preview",
"model", "system_prompt", "cwd", "git_branch", "git_repo_root",
):
if key in tip_row:
merged[key] = tip_row[key]
merged["_lineage_root_id"] = s["id"]
# Every id on the chain, intermediates included. Root and tip
# alone are not enough client-side: a persisted tile or route
# can hold a MIDDLE segment's id (it was the tip when opened,
# then rotated again), and with only the root/tip pair such a
# surface can no longer prove it names this conversation —
# which is how one chat ends up open twice after compaction.
merged["_lineage_ids"] = chain_by_root.get(s["id"]) or None
projected.append(merged)
sessions = projected
# Derive read state per surfaced conversation. ``last_read_at`` is
# lineage-stamped by set_session_read, so a projected row's root
# watermark and its tip's are the same value — comparing it against
# the tip's last_active is correct either way.
for s in sessions:
s["unread"] = self.session_unread(s)
return sessions
def session_lifecycle_statuses(
self, session_ids: List[str]
) -> Dict[str, str]:
"""Classify each session's lifecycle state from its LAST message row.
Returns ``{session_id: status}`` where status is one of:
- ``'complete'`` — last message is a normal assistant reply
- ``'interrupted'`` — last message is a user turn, a pending assistant
tool call (no tool result followed), or a tool result the assistant
never responded to
- ``'error'`` — last message carries an error finish_reason
- ``'empty'`` — session has no messages
Cost-bounded by design: one query that resolves each listed session's
newest message id via ``MAX(id)`` (an index seek on
``idx_messages_session_id``) and joins back for that single row's
role/tool_calls/finish_reason. Never scans transcripts, so it stays
cheap on large databases regardless of total message volume.
"""
ids = [sid for sid in (session_ids or []) if sid]
if not ids:
return {}
statuses: Dict[str, str] = {sid: "empty" for sid in ids}
placeholders = ",".join("?" for _ in ids)
query = f"""
SELECT m.session_id, m.role,
m.tool_calls IS NOT NULL AS has_tool_calls,
m.finish_reason
FROM messages m
JOIN (
SELECT session_id, MAX(id) AS max_id
FROM messages
WHERE session_id IN ({placeholders})
GROUP BY session_id
) latest ON m.id = latest.max_id
"""
rows = self._read_all(query, ids)
for row in rows:
statuses[row["session_id"]] = classify_session_status(
role=row["role"],
has_tool_calls=bool(row["has_tool_calls"]),
finish_reason=row["finish_reason"],
)
return statuses
# =========================================================================
# Message storage
# =========================================================================
# Sentinel prefix used to distinguish JSON-encoded structured content
# (multimodal messages: lists of parts like text + image_url) from plain
# string content. The NUL byte is not legal in normal text, so this
# cannot collide with real user content.
_CONTENT_JSON_PREFIX = "\x00json:"
#: Key under which message reactions live inside ``display_metadata``.
#: Reactions share the existing per-message JSON column rather than a side
#: table so they survive rewind/compaction row rewrites with the row itself.
REACTIONS_METADATA_KEY = "reactions"
# Columns every conversation projection decodes. Shared by
# get_messages_as_conversation and get_resume_conversations so a single
# SELECT can feed both the model-fed and display views. ``active`` rides
# along so a display read can split the compaction-archived rows from the
# live set (and feed _dedupe_display_generations) without a second query.
_CONVERSATION_ROW_COLUMNS = (
"id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, "
"_compressed_summary, timestamp, active, "
"api_content, display_kind, display_metadata"
)
def assert_export_safe(
self,
session_id: str,
max_messages: Optional[int] = None,
) -> int:
"""Return active row count or reject an unsafe in-memory export.
Exporting one session does not include compression ancestors, so this
guard deliberately counts only the requested segment. The limited
subquery stops as soon as it proves the transcript exceeds the bound.
``max_messages=None`` resolves the limit from config
(``sessions.max_export_messages``); 0 disables the guard and returns
the active row count without raising.
"""
if max_messages is None:
max_messages = resolved_max_export_messages()
if max_messages < 0:
raise ValueError("max_messages must be non-negative")
if max_messages == 0:
# Guard disabled by config — skip the COUNT; live callers use
# this for its raise side effect only (and skip calling it
# entirely when the limit is 0).
return 0
row = self._read_one(
"SELECT COUNT(*) FROM ("
"SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?"
")",
(session_id, max_messages + 1),
)
message_count = int(row[0] if row else 0)
if message_count > max_messages:
raise SessionExportTooLargeError(session_id, message_count, max_messages)
return message_count
def _is_explicit_branch_session(self, session_id: str) -> bool:
"""Return whether *session_id* is a copied user-facing branch.
Branches and compression continuations both use ``parent_session_id``,
but they have different history semantics: a branch owns a copied
transcript, while a compression continuation needs its ended parent's
archived rows for display. The durable ``_branched_from`` marker is the
existing discriminator written by all branch creation paths.
"""
if not session_id:
return False
row = self._read_one(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
)
if row is None:
return False
raw_config = row["model_config"] if hasattr(row, "keys") else row[0]
if not raw_config:
return False
try:
config = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
except (json.JSONDecodeError, TypeError):
return False
return isinstance(config, dict) and bool(config.get("_branched_from"))
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
if not session_id:
return [session_id]
chain = []
current = session_id
seen = set()
with self._read_ctx() as conn:
for _ in range(100):
if not current or current in seen:
break
seen.add(current)
chain.append(current)
row = conn.execute(
"SELECT parent_session_id FROM sessions WHERE id = ?",
(current,),
).fetchone()
if row is None:
break
current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
return list(reversed(chain)) or [session_id]
# =========================================================================
# Rewind (soft-delete) — see /rewind slash command + issue #21910
# =========================================================================
# =========================================================================
# Search
# =========================================================================
def search_sessions(
self,
source: str = None,
limit: int = 20,
offset: int = 0,
workspace_key: str = None,
) -> List[Dict[str, Any]]:
"""List sessions, optionally filtered by source.
Returns rows enriched with a computed ``last_active`` column
(freshest of ``last_activity_at`` and latest message timestamp,
else ``started_at``), ordered by most-recently-used first.
Pass ``workspace_key`` to scope rows to one workspace - matching
:func:`workspace_key` semantics (git repo root, else cwd). Used by
``hermes -c``/``--resume`` so the "last" session is the last one in
the *current* workspace, not the global MRU.
"""
select_with_last_active = (
"SELECT s.*, "
"COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, "
f"{_sql_session_last_active('s')} AS last_active "
"FROM sessions s "
"LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
)
where_clauses = []
params: list = []
if source:
where_clauses.append("s.source = ?")
params.append(source)
if workspace_key:
ws_clause, ws_params = _workspace_key_clause(workspace_key)
where_clauses.append(ws_clause)
params.extend(ws_params)
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
params.extend([limit, offset])
return [self._session_row_dict(row) for row in self._read_all(
f"{select_with_last_active}"
f"{where_sql} "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
params,
)]
# =========================================================================
# Utility
# =========================================================================
def session_count(
self,
source: str = None,
sources: List[str] = None,
cwd_prefix: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
exclude_sources: List[str] = None,
) -> int:
"""Count sessions, optionally filtered by source.
Pass ``exclude_children=True`` to count only the conversations that
``list_sessions_rich`` surfaces (root + branch/reset sessions), hiding
sub-agent runs and compression continuations. Use it whenever the count
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
totals) so the total matches the number of listable rows — otherwise the
raw row count is inflated by children and "load more" never settles.
Pass ``exclude_sources`` to drop whole source classes from the count
(e.g. ``["cron"]`` so the recents "load more" total matches a
cron-excluded ``list_sessions_rich`` page and doesn't keep "load more"
stuck on for buried scheduler sessions).
"""
where_clauses = []
params = []
if exclude_children:
# Mirror list_sessions_rich's child-exclusion clause exactly so the
# count lines up with the rows: roots plus user-visible branch/reset
# children.
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
include_sources = [source] if source else list(sources or [])
if include_sources:
placeholders = ",".join("?" for _ in include_sources)
where_clauses.append(f"s.source IN ({placeholders})")
params.extend(include_sources)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
return self._read_one(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)[0]
def session_count_ge(self, n: int = 1) -> bool:
"""Check if at least N sessions exist (archived included).
Short-circuits via LIMIT — much cheaper than ``session_count()``,
which pays a full index scan for its default ``archived = 0``
filter (measured 543us vs 4us on a 20k-session DB). Archived
sessions count: every caller so far asks "has this install ever
had sessions", and an archived session is still a created one.
Use this instead of ``session_count() >= n`` when the exact count
is irrelevant.
"""
rows = self._read_all("SELECT 1 FROM sessions LIMIT ?", (n,))
return len(rows) >= n
def session_count_by_source(
self,
*,
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
) -> Dict[str, int]:
"""Return a ``{source: count}`` dict via a single ``GROUP BY`` query.
Replaces the O(N) ``list_sessions_rich`` histogram loop with an
aggregate query. When ``exclude_children`` is False the query uses
``idx_sessions_source``; when True, the child-exclusion predicates
require a full table scan (same as ``session_count`` and
``list_sessions_rich``).
``exclude_children=True`` mirrors ``list_sessions_rich`` visibility
(roots + branch/reset sessions, excluding sub-agent runs, delegates,
and compression continuations) so the source counts match what the
Sessions page actually lists.
"""
where_clauses = []
params: list = []
if exclude_children:
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
with self._read_ctx() as conn:
if self._conn is None:
raise RuntimeError("SessionDB connection is closed")
rows = conn.execute(
"SELECT COALESCE(NULLIF(s.source, ''), 'cli') AS source, COUNT(*) AS count "
f"FROM sessions s{where_sql} "
"GROUP BY COALESCE(NULLIF(s.source, ''), 'cli') "
"ORDER BY count DESC",
params,
).fetchall()
return {str(row["source"]): int(row["count"] or 0) for row in rows}
# =========================================================================
# Export and cleanup
# =========================================================================
def declared_scope_identity(self, session_id: str) -> Tuple[bool, str]:
"""Fork verdict and recorded ``source`` for *session_id*, in ONE read.
``agent/prompt_cache_scope.py`` needs both to resolve a host-declared
conversation scope, and both live on the same ``sessions`` row; asking
for them separately read that row twice per resolution (@teknium1 on
#98811). The marker rules stay here, beside
:meth:`is_explicit_fork_child`, instead of being re-implemented by the
caller.
A missing row is not a fork and has no source, which is the right
answer before ``_ensure_db_session`` persists it. This raises whatever
:meth:`get_session` raises: the caller fails closed on a DB error, and
merging the two reads cannot weaken that, because the fork check was
already the first of the two.
"""
session = self.get_session(session_id)
if not session:
return False, ""
return (
self._is_explicit_fork_child_row(session),
str(session.get("source") or "").strip(),
)
@staticmethod
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
"""Remove on-disk transcript files for a session.
Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
``request_dump_{session_id}_*.json`` files left by the gateway.
Silently skips files that don't exist and swallows OSError so a
filesystem hiccup never blocks a DB operation.
"""
if sessions_dir is None:
return
for suffix in (".json", ".jsonl"):
p = sessions_dir / f"{session_id}{suffix}"
try:
p.unlink(missing_ok=True)
except OSError:
pass
# request_dump files use session_id as a prefix component
try:
for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
except OSError:
pass
def get_session_delete_targets(self, session_id: str) -> List[str]:
"""Return every session row that :meth:`delete_session` would remove.
The requested session is first, followed by its recursively discovered
delegate/subagent children. Branch and compression children are not
included because deletion preserves them by orphaning their parent
reference.
"""
with self._read_ctx() as conn:
exists = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
).fetchone()
if not exists:
return []
# Use the borrowed read connection, never self._conn: handing the
# shared writer connection to a helper here executes on it without
# self._lock — the same unsynchronized-read class as #99349/#90734.
delegate_ids = _collect_delegate_child_ids(conn, [session_id])
return [session_id, *sorted(delegate_ids)]
def delete_session(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
expected_delete_ids: Optional[List[str]] = None,
) -> bool:
"""Delete a session and all its messages.
Delegate subagent children (``model_config._delegate_from``) are
cascade-deleted with the parent so they never resurface in session
pickers as orphaned rows. Branch / compression children are orphaned
(``parent_session_id → NULL``) so they remain accessible independently.
When *sessions_dir* is provided, also removes on-disk transcript
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted
session. When *expected_delete_ids* is provided, deletion proceeds only
if the parent plus delegate cascade still matches that exact set. This
lets export-before-delete callers fail closed if a new delegate appears
after they materialize their archive. The delegate tree is re-walked
inside the write transaction on purpose (TOCTOU guard); the cost is
accepted for correctness. Returns True if the session was found and
deleted.
"""
removed_delegate_ids: List[str] = []
expected_ids = (
set(expected_delete_ids) if expected_delete_ids is not None else None
)
def _do(conn):
cursor = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
)
if cursor.fetchone() is None:
return False
if expected_ids is not None:
actual_ids = {
session_id,
*_collect_delegate_child_ids(conn, [session_id]),
}
if actual_ids != expected_ids:
return False
removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id]))
# Orphan remaining child sessions (branches, etc.) so FK is satisfied.
conn.execute(
"UPDATE sessions SET parent_session_id = NULL "
"WHERE parent_session_id = ?",
(session_id,),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
self._delete_unreferenced_system_prompts(conn)
return True
deleted = self._execute_write(_do)
if deleted:
for delegate_id in removed_delegate_ids:
self._remove_session_files(sessions_dir, delegate_id)
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.
A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)
The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved — a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
if cursor.rowcount > 0:
self._delete_unreferenced_system_prompts(conn)
return cursor.rowcount > 0
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) — selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Delegate subagent children (``model_config._delegate_from``) are
cascade-deleted with their parent; branch children are orphaned
(``parent_session_id → NULL``) so they stay accessible.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
removed_delegate_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
removed_delegate_ids.extend(_delete_delegate_children(conn, existing))
# Orphan remaining children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
self._delete_unreferenced_system_prompts(conn)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_delegate_ids:
self._remove_session_files(sessions_dir, sid)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
#: Shared selector for :meth:`count_empty_sessions` and
#: :meth:`delete_empty_sessions` so the badge and the sweep agree.
#:
#: ``message_count`` tracks live (``active = 1``) rows only; rewind
#: (:meth:`replace_messages` w/ ``archive_dropped``) and in-place
#: compaction (:meth:`archive_and_compact`) reset it to 0 while keeping
#: dropped turns on disk as ``active = 0`` — the only recoverable copy
#: (#70516 / #80763 / #82756). The ``NOT EXISTS`` probe is the authority;
#: ``message_count = 0`` stays as a cheap prefilter. Same shape as every
#: other emptiness guard in this module. (#95868)
_EMPTY_SESSION_WHERE = (
"message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0 "
"AND NOT EXISTS ("
"SELECT 1 FROM messages WHERE messages.session_id = sessions.id"
")"
)
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = the session holds no message rows at all AND has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent — is never sniped out from under
the runtime.
Emptiness is decided by :data:`_EMPTY_SESSION_WHERE` — see that
constant for why the ``NOT EXISTS`` probe is needed instead of
trusting ``message_count`` alone.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
return self._read_one(f"SELECT COUNT(*) FROM sessions WHERE {self._EMPTY_SESSION_WHERE}")[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (:data:`_EMPTY_SESSION_WHERE`) so we
never touch a live session, one the user deliberately archived,
or one whose transcript survives as soft-archived rows.
* Orphans any child whose parent is in the kill list — children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic — a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply —
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
f"SELECT id FROM sessions WHERE {self._EMPTY_SESSION_WHERE}"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — the selector's
# ``NOT EXISTS`` probe already proved these sessions own no
# message rows — but a row inserted between the SELECT and
# this statement would otherwise be left dangling, so we
# still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
self._delete_unreferenced_system_prompts(conn)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def archive_sessions(
self,
older_than_days: Optional[float] = None,
source: str = None,
**filters,
) -> int:
"""Bulk-archive (soft-hide) every session matching the filters.
Same filter surface as :meth:`prune_sessions`, but instead of deleting
rows it flips ``archived = 1`` via :meth:`set_session_archived` so
each match's compression lineage is archived as a unit (an unarchived
compression root would otherwise resurrect the conversation in
Desktop's projected list). Nothing is deleted; messages and transcript
files are untouched. Returns the number of sessions matched.
``archived`` defaults to ``False`` here (only select rows not yet
archived) so repeat runs are idempotent no-ops.
"""
filters.setdefault("archived", False)
rows = self.list_prune_candidates(
older_than_days=older_than_days, source=source, **filters
)
for row in rows:
self.set_session_archived(row["id"], True)
return len(rows)
# ── Meta key/value (for scheduler bookkeeping) ──
def get_meta(self, key: str) -> Optional[str]:
"""Read a value from the state_meta key/value store."""
# Kept on self._lock (not _read_ctx) because callers like
# fts_rebuild_step read progress before entering a write
# transaction, and the read-only WAL connection sees only
# committed data — a pending write transaction's uncommitted
# meta writes would be invisible. This is a cheap point lookup,
# not the convoy bottleneck the read-path split targets.
with self._lock:
row = self._conn.execute(
"SELECT value FROM state_meta WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
return row[0]
def set_meta(
self, key: str, value: str, *, cursor: Optional[sqlite3.Cursor] = None
) -> None:
"""Write a value to the state_meta key/value store.
When ``cursor`` is provided the write is issued on that cursor
inline (used during ``_init_schema``, which already holds an open
transaction — routing through ``_execute_write`` there would nest
BEGIN IMMEDIATE and deadlock). Otherwise a normal write transaction
is used.
"""
if cursor is not None:
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
return
self._write_sql(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def retag_kanban_worker_sessions(self, workspaces_root: str) -> int:
"""Retag legacy kanban worker rows from ``cli`` to ``kanban``.
Workers used to spawn without ``HERMES_SESSION_SOURCE``, so their runs
landed as untitled ``cli`` rows and the sidebar rendered one per attempt
labeled with the worker's own prompt. New workers tag themselves; this
reclaims the rows already on disk so they drop out of the session lists
too. Identified by cwd under the board's workspaces root — a path only
the dispatcher ever runs a session in.
Gated per workspaces root (``state_meta``) so each board reclaims its
own rows exactly once. Returns the number of rows retagged.
"""
prefix = str(workspaces_root).rstrip("/\\")
if not prefix:
return 0
gate = f"kanban_worker_source_retagged:{prefix}"
if self.get_meta(gate) == "1":
return 0
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET source = 'kanban' "
"WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\\')",
(prefix, _escape_like(prefix) + "/%"),
)
# Read rowcount before set_meta reuses this cursor for its INSERT,
# which would otherwise overwrite it with the meta write's count.
retagged = cursor.rowcount or 0
self.set_meta(gate, "1", cursor=cursor)
return retagged
return self._execute_write(_do)
def list_meta_prefix(self, prefix: str) -> List[Tuple[str, str]]:
"""Return ``[(key, value), ...]`` for state_meta keys with ``prefix``.
Used by feature stores that persist one row per session under a
namespaced key (e.g. ``loop:<session_id>``) and need to enumerate
them across sessions (the gateway's idle /loop wakeup watcher).
``prefix`` is matched literally — LIKE wildcards in it are escaped.
"""
if not prefix:
return []
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = self._read_all(
"SELECT key, value FROM state_meta WHERE key LIKE ? ESCAPE '\\'",
(escaped + "%",),
)
return [(row[0], row[1]) for row in rows]
# ── Space reclamation ──
# FTS5 virtual tables whose b-tree segments we merge on optimize. The
# trigram table is created lazily / may be disabled, and the cjk-bigram
# table only exists (and is only queryable) when the loadable tokenizer
# is present — so we probe each before touching it (see optimize_fts).
_FTS_TABLES = ("messages_fts", "messages_fts_trigram", "messages_fts_cjk")
def maybe_auto_archive(
self,
idle_days: float = 3,
min_interval_hours: int = 24,
exclude_pinned: bool = True,
) -> Dict[str, Any]:
"""Idempotent auto-archive: soft-hide sessions idle for ``idle_days``.
Sibling of :meth:`maybe_auto_prune_and_vacuum` but non-destructive —
it archives (hides) rather than deletes, and ages on last activity
(see :meth:`archive_stale_sessions`) rather than creation. Records the
last run in ``state_meta['last_auto_archive']`` so calls within
``min_interval_hours`` no-op; safe to call opportunistically (startup
hooks, or when the Desktop backend lists sessions).
Never raises. Returns a dict with:
- ``"skipped"`` (bool) — within min_interval_hours of last run
- ``"archived"`` (int) — sessions archived this run
- ``"error"`` (str, optional) — present only on failure
"""
result: Dict[str, Any] = {"skipped": False, "archived": 0}
try:
last_raw = self.get_meta("last_auto_archive")
now = time.time()
if last_raw:
try:
if now - float(last_raw) < min_interval_hours * 3600:
result["skipped"] = True
return result
except (TypeError, ValueError):
pass # corrupt meta; treat as no prior run
archived = self.archive_stale_sessions(
idle_days, exclude_pinned=exclude_pinned
)
result["archived"] = archived
# Record even a zero-archive run so we don't re-sweep every call
# within the interval window.
self.set_meta("last_auto_archive", str(now))
if archived > 0:
logger.info(
"state.db auto-archive: archived %d session(s) idle >= %s days",
archived,
idle_days,
)
except Exception as exc:
logger.warning("state.db auto-archive failed: %s", exc)
result["error"] = str(exc)
return result
# ── Handoff (cross-platform session transfer) ──────────────────────────
#
# State machine:
# None — no handoff in flight
# "pending" — CLI requested handoff, gateway hasn't picked it up yet
# "running" — gateway is processing (session switch + synthetic turn)
# "completed"— gateway successfully delivered the synthetic turn
# "failed" — gateway hit an error; reason in handoff_error
#
# The CLI writes "pending" then poll-waits for terminal state. The gateway
# watcher transitions pending→running→{completed,failed}.
class AsyncSessionDB:
"""Async door onto SessionDB: offloads each call via asyncio.to_thread so a blocking SQLite call never freezes the event loop. Generic forwarder — the audit confirms no method returns a live cursor/generator."""
def __init__(self, db: "SessionDB") -> None:
self._db = db
def __getattr__(self, name: str):
attr = getattr(self._db, name)
if not callable(attr):
return attr
async def _offloaded(*args, **kwargs):
return await asyncio.to_thread(attr, *args, **kwargs)
return _offloaded