create_main used O_WRONLY|O_CREAT on the main file and closed the fd, which drops this process's POSIX locks whenever state.db already exists (the gateway's own async_delegation import path). O_EXCL restricts the descriptor to a brand-new inode; existing files take the chmod(2) path. Refs #109786 #109687
1613 lines
85 KiB
Python
1613 lines
85 KiB
Python
#!/usr/bin/env python3
|
|
"""SQLite state store for Hermes Agent: session metadata, message history, model
|
|
config, FTS5 search. WAL mode (concurrent readers + one writer); compression
|
|
splits sessions via parent_session_id chains; sessions are source-tagged
|
|
('cli', 'telegram', ...). Batch-runner / RL trajectories live elsewhere.
|
|
"""
|
|
|
|
import asyncio
|
|
import atexit
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import random
|
|
import re
|
|
import sqlite3
|
|
import stat
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from agent.message_sanitization import _sanitize_surrogates
|
|
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
|
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
|
|
|
|
from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity
|
|
from hermes_state_errors import (
|
|
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
|
|
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
|
|
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_malformed_db_error,
|
|
is_malformed_schema_error,
|
|
)
|
|
from hermes_state_guard import (
|
|
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root,
|
|
_set_last_init_error, get_last_init_error,
|
|
)
|
|
from hermes_state_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for
|
|
from hermes_state_sessions import SessionSessionsMixin
|
|
from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension
|
|
from hermes_state_portability import SessionPortabilityMixin
|
|
from hermes_state_telegram import SessionTelegramTopicsMixin
|
|
from hermes_state_schema import SessionSchemaMixin
|
|
import hermes_state_holders as _state_holders
|
|
from hermes_state_dbfile import (
|
|
_canonical_sqlite_path, _connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
|
|
_read_sqlite_application_id, _stat_sqlite_sidecar_identity,
|
|
_watched_sqlite_sidecar_paths, has_invalid_sqlite_header_preopen, is_zeroed_state_db, quarantine_cross_process_lock,
|
|
quarantine_invalid_state_db,
|
|
RetiredGenerationCaptureError, capture_retired_wal_generation, refuse_deleted_wal_generation,
|
|
)
|
|
from hermes_state_messages import SessionMessagesMixin
|
|
from hermes_state_wal import (
|
|
_WAL_INCOMPAT_MARKERS, _on_disk_journal_mode, apply_database_pragmas, apply_wal_with_fallback,
|
|
)
|
|
from hermes_state_repair import _claim_repair_attempt, 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_MESSAGES = 20_000 # resume/export guard default
|
|
|
|
|
|
def _configured_transcript_limit(key: str, fallback: int = _MAX_SAFE_MESSAGES) -> int:
|
|
"""``sessions.<key>`` from config.yaml (lazy import: circular at load), else *fallback*; 0 disables."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
value = (load_config_readonly().get("sessions") or {}).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:
|
|
return _configured_transcript_limit("max_resume_messages")
|
|
|
|
|
|
def resolved_max_export_messages() -> int:
|
|
return _configured_transcript_limit("max_export_messages")
|
|
|
|
|
|
class SessionResumeTooLargeError(ValueError):
|
|
def __init__(
|
|
self, message_count: int, limit: int = _MAX_SAFE_MESSAGES, scope: str = "across its lineage",
|
|
):
|
|
self.message_count, self.limit = message_count, 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_MESSAGES):
|
|
self.session_id, self.message_count, self.limit = session_id, message_count, limit
|
|
super().__init__(
|
|
f"session '{session_id}' has at least {message_count} active messages; "
|
|
f"safe in-memory export limit is {limit}"
|
|
)
|
|
|
|
|
|
def _compression_lock_holder_process_is_dead(holder: str) -> bool:
|
|
"""True only when a ``pid=<n>`` lock holder's local PID is provably gone.
|
|
Reclaim on kernel proof only: unstructured/same-process holders (another
|
|
thread's live lease) and any probe doubt keep the lease until TTL expiry
|
|
(PID reuse must never steal a live lease; a wrongly-kept one self-heals)."""
|
|
match = re.search(r"(?:^|:)pid=(\d+)(?::|$)", holder or "")
|
|
pid = int(match.group(1)) if match else 0
|
|
if pid <= 0 or pid == os.getpid():
|
|
return False
|
|
if psutil is not None:
|
|
try:
|
|
return not psutil.pid_exists(pid) # recycled PIDs read as alive (conservative)
|
|
except Exception:
|
|
return False
|
|
# psutil-less fallback is POSIX-only: on Windows os.kill(pid, 0) maps sig=0 to
|
|
# CTRL_C_EVENT and can kill the target's console group.
|
|
if os.name == "nt":
|
|
return False
|
|
try:
|
|
os.kill(pid, 0) # windows-footgun: ok — nt early-returns just above
|
|
except ProcessLookupError:
|
|
return True
|
|
except (OSError, OverflowError): # PermissionError is an OSError: alive but foreign
|
|
return False
|
|
return False
|
|
|
|
|
|
def _scrub_surrogates(value: Any) -> Any:
|
|
"""Replace lone surrogates in text (sqlite3 raises UnicodeEncodeError, aborting the whole write)."""
|
|
return _sanitize_surrogates(value) if isinstance(value, str) else value
|
|
|
|
|
|
# Billing buckets that aren't a routable provider identity: a session that persisted only
|
|
# one of these (never ran /model) falls back to the config default. Shared by
|
|
# session_gateway_runtime and tui_gateway.server so they cannot drift.
|
|
_BARE_BILLING_PROVIDERS = frozenset({"auto", "custom"})
|
|
|
|
T = TypeVar("T")
|
|
|
|
# Import-time snapshot lets _default_db_path() detect a re-pointed DEFAULT_DB_PATH
|
|
# (tests monkeypatch the constant directly).
|
|
DEFAULT_DB_PATH = _IMPORT_DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
|
|
|
# Back off from read-only opens after one fails: not per query, but 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 writer's checkpoint/
|
|
# reset/frame flush surfaces "disk I/O error" to a concurrent mode=ro reader for a millisecond-
|
|
# wide window — the ro connection cannot perform WAL recovery because recovery writes the -shm
|
|
# index, which mode=ro refuses. The writer closes the window on its own, so a few short retries
|
|
# make the open succeed instead of 500-ing the whole /api/sessions poll (or any other ro opener).
|
|
# Deliberately NOT for writable opens: a writer owns the transition, so an IOERR there is a real
|
|
# storage/fd problem. A persistent IOERR still exhausts the budget and propagates.
|
|
_READ_ONLY_IOERR_RETRY_ATTEMPTS, _READ_ONLY_IOERR_RETRY_BACKOFF_S = 3, 0.05
|
|
|
|
|
|
def _default_db_path() -> Path:
|
|
"""Default state DB path at CALL time: a re-pointed ``DEFAULT_DB_PATH`` wins, else
|
|
``get_hermes_home()`` is resolved fresh (a runtime HERMES_HOME redirect works regardless of import)."""
|
|
return DEFAULT_DB_PATH if DEFAULT_DB_PATH != _IMPORT_DEFAULT_DB_PATH else get_hermes_home() / "state.db"
|
|
|
|
|
|
# Live-DB guard knobs live HERE (not in hermes_state_guard): the hermetic conftest monkeypatches
|
|
# ``hermes_state._STATE_DB_GUARD_BYPASS`` (``@pytest.mark.live_system_guard_bypass`` escape hatch)
|
|
# and ``_EXTRA_DENY_ROOTS`` (the pre-sandbox root, so custom-HERMES_HOME deployments are covered).
|
|
_STATE_DB_GUARD_BYPASS = False
|
|
_STATE_DB_GUARD_EXTRA_DENY_ROOTS: Tuple[Path, ...] = ()
|
|
|
|
|
|
def _ensure_test_isolation(db_path: Path) -> None:
|
|
"""Raise before any connection/mkdir/pragma/byte probe when a pytest-context process
|
|
(env OR ancestry) resolves a production DB.
|
|
|
|
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) or not _in_test_context():
|
|
return
|
|
try:
|
|
resolved = Path(db_path).expanduser().resolve()
|
|
except Exception:
|
|
return
|
|
roots = [r for r in (_real_platform_state_root(),) if r is not None]
|
|
for extra in _STATE_DB_GUARD_EXTRA_DENY_ROOTS:
|
|
try:
|
|
roots.append(Path(extra).expanduser().resolve())
|
|
except Exception:
|
|
continue
|
|
for root in 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."
|
|
)
|
|
|
|
|
|
def _secure_state_db_files(db_path: Path, *, create_main: bool = False) -> None:
|
|
"""Create/tighten a writable state database and its sidecars to 0600.
|
|
|
|
SQLite otherwise creates ``state.db``, ``-wal``, and ``-shm`` according to
|
|
the process umask (commonly 0644 under 0022). Read-only SessionDB
|
|
attachments never call this helper and remain observational.
|
|
|
|
Existing files are tightened with ``chmod(2)`` on the path: opening the
|
|
file and closing that descriptor would drop every POSIX ``fcntl`` lock the
|
|
process holds on its inode — including the locks of an already-open SQLite
|
|
connection to the same database. A lock-losing close in one process lets a
|
|
sibling's connection take the shared-memory DMS exclusively at its own
|
|
close, checkpoint, and unlink the sidecars while long-lived holders
|
|
(gateway, desktop ``hermes serve``) keep using the deleted inodes.
|
|
"""
|
|
if os.name == "nt":
|
|
return
|
|
|
|
main_path = db_path
|
|
if create_main:
|
|
# O_EXCL: only a brand-new inode gets a descriptor. Opening an existing
|
|
# file here and closing it would drop this process's POSIX locks on it.
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
if hasattr(os, "O_CLOEXEC"):
|
|
flags |= os.O_CLOEXEC
|
|
try:
|
|
fd = os.open(main_path, flags, 0o600)
|
|
except FileExistsError:
|
|
pass
|
|
except IsADirectoryError:
|
|
# Not a database file at all; sqlite3.connect() raises the
|
|
# canonical error for this, and a directory leaks no row data.
|
|
return
|
|
else:
|
|
os.close(fd)
|
|
|
|
for path in (
|
|
main_path,
|
|
db_path.with_name(db_path.name + "-wal"),
|
|
db_path.with_name(db_path.name + "-shm"),
|
|
):
|
|
# fchmod on an fd of a pre-existing file cannot be used here: close(fd)
|
|
# would release this process's POSIX locks on that inode, stripping the
|
|
# locks of any live SQLite connection to the same database. chmod(2)
|
|
# never opens the file, so it leaves the lock state untouched.
|
|
try:
|
|
st = os.lstat(path)
|
|
except FileNotFoundError:
|
|
continue
|
|
if stat.S_ISLNK(st.st_mode):
|
|
# Refuse a planted symlink exactly like O_NOFOLLOW would.
|
|
continue
|
|
if not stat.S_ISREG(st.st_mode):
|
|
continue
|
|
os.chmod(path, 0o600)
|
|
|
|
|
|
# Openings of the background-review harness prompts (agent/background_review.py).
|
|
_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:
|
|
"""Persisted harness prompt (older builds wrote the forked curator's turns
|
|
into real sessions; replaying them hijacks the session)."""
|
|
if not isinstance(msg, dict) or msg.get("role") not in {"user", "system"}:
|
|
return False
|
|
content = msg.get("content")
|
|
return isinstance(content, str) and content.lstrip().startswith(_REVIEW_HARNESS_PREFIXES)
|
|
|
|
|
|
def _strip_background_review_harness(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Drop harness messages and the curator-mode assistant reply that immediately followed each."""
|
|
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":
|
|
continue # the curator-mode reply to the harness prompt
|
|
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:
|
|
"""Assistant tool-call turn whose content is a bare ``[marker]`` (an older
|
|
conversation_loop persisted a local template's marker as the final response)."""
|
|
if not isinstance(msg, dict) or msg.get("role") != "assistant" or not msg.get("tool_calls"):
|
|
return False
|
|
content = msg.get("content")
|
|
return isinstance(content, str) and bool(_STALE_TOOL_CALL_MARKER_RE.fullmatch(content.strip()))
|
|
|
|
|
|
def _strip_stale_tool_call_markers(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Blank stale ``[marker]`` assistant content (replaying it teaches the model
|
|
to keep emitting it); tool_call/result pairing stays intact."""
|
|
repaired = 0
|
|
for msg in filter(_is_stale_tool_call_marker_message, messages):
|
|
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:
|
|
"""User-facing message with the captured init cause (+ WAL-docs hint for NFS/SMB locking failures)."""
|
|
cause = get_last_init_error()
|
|
if not cause:
|
|
return f"{prefix}."
|
|
hint = " (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html)"
|
|
return f"{prefix}: {cause}{hint if any(m in cause.lower() for m in _WAL_INCOMPAT_MARKERS) else ''}."
|
|
|
|
|
|
# Auto-repair at most once per DB path per process (no repair loops; serialises concurrent
|
|
# web_server / gateway opens on the same malformed file).
|
|
_repair_attempted_paths: set[str] = set()
|
|
_repair_attempt_lock = threading.Lock()
|
|
# Cross-process schema-surgery lock timeout (``_repair_attempt_lock`` covers one interpreter
|
|
# only); sized for the slowest legitimate holder (VACUUM, multi-GB DB).
|
|
_REPAIR_LOCK_TIMEOUT_SECONDS = 120.0
|
|
_IS_WINDOWS = sys.platform == "win32"
|
|
|
|
|
|
def _close_time_checkpoint_configurable() -> bool:
|
|
"""Whether this runtime can switch off SQLite's close-time checkpoint (Python 3.12+ ``setconfig``)."""
|
|
return (getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None) is not None
|
|
and hasattr(sqlite3.Connection, "setconfig"))
|
|
|
|
|
|
def divert_session_transcript_jsonl(session_id: str, messages) -> "Optional[Path]":
|
|
"""Append pending messages to HERMES_HOME/sessions/<id>.jsonl (state.db was replaced under a
|
|
live process). Returns the path, or None if 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 msg is not None:
|
|
record = msg if isinstance(msg, dict) else {"content": str(msg)}
|
|
handle.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
|
return path
|
|
|
|
|
|
# Process-wide shared SessionDB registry: long-lived in-process callers share ONE writer
|
|
# connection per resolved path via hermes_state_registry.acquire(); one-shots use SessionDB() + close().
|
|
def _foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
|
|
"""Compatibility delegate to the state-holder authority."""
|
|
return _state_holders.foreign_state_db_holders(db_path)
|
|
|
|
|
|
# ── Process-wide shared SessionDB registry (#90837) ── lives in hermes_state_registry.py (acquire /
|
|
# release / close_all / release_or_close). Long-lived in-process callers (gateway, tui_gateway, cron,
|
|
# in-process tools) share ONE writer connection per resolved path via hermes_state_registry.acquire(); CLI
|
|
# one-shots, recovery flows, and read-only cross-profile opens use SessionDB() directly with their own close().
|
|
|
|
|
|
class SessionDB(
|
|
SessionSessionsMixin, SessionFtsSetupMixin, SessionSearchMixin, SessionSchemaMixin,
|
|
SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin,
|
|
SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin,
|
|
SessionMessagesMixin,
|
|
):
|
|
"""SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL)."""
|
|
|
|
# Only these state-owned producers join automatic stale-open reconciliation; messaging/UI
|
|
# sources have their own lifecycle owners; unknown sources fail closed.
|
|
# See #60609.
|
|
_AUTO_PRUNE_STALE_OPEN_SOURCES: Tuple[str, ...] = (
|
|
"cli", "cron", "kanban", "acp", "api_server", "subagent", "tool",
|
|
)
|
|
|
|
# ── Write-contention tuning ──
|
|
# SQLite's deterministic busy handler convoys under many hermes processes: keep its
|
|
# timeout short (1s) and retry with random jitter. Patience is TIME-based (a sibling
|
|
# legitimately holds the lock for seconds: checkpoint at close, VACUUM, recovery, FTS
|
|
# optimize); attempt-counted budgets destroyed turns on a healthy store. Transcript
|
|
# writes (failure aborts the turn) get the long budget; observation-only activity
|
|
# writes sit on the response-critical path and get a sub-second one.
|
|
_WRITE_PATIENCE_S, _TRANSCRIPT_WRITE_PATIENCE_S, _ACTIVITY_WRITE_PATIENCE_S = 20.0, 60.0, 0.5
|
|
# A live compression lock gets a short wait (compression publishes in seconds), but the lease
|
|
# is a correctness boundary: a writer still locked out afterwards is refused.
|
|
# 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.
|
|
# 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, _WRITE_RETRY_MAX_S = 0.020, 0.150 # fast jitter for the first _SLOW_AFTER_S
|
|
_WRITE_RETRY_SLOW_AFTER_S = 2.0
|
|
_WRITE_RETRY_SLOW_MIN_S, _WRITE_RETRY_SLOW_MAX_S = 0.250, 1.000
|
|
# PASSIVE WAL checkpoint every N successful writes.
|
|
_CHECKPOINT_EVERY_N_WRITES = 50
|
|
# Bounded FTS ``'merge'`` (ms of lock each) instead of ``'optimize'`` (9-18s per index on a 10GB
|
|
# DB, longer than a writer's patience); up to _COMMANDS_PER_PASS per index, stopping on no-progress.
|
|
_FTS_MERGE_EVERY_N_WRITES, _FTS_MERGE_MAX_PAGES_PER_INDEX, _FTS_MERGE_COMMANDS_PER_PASS = 1000, 500, 4
|
|
# Imports cap lower than exports: an import holds one BEGIN IMMEDIATE.
|
|
_IMPORT_MAX_SESSIONS, _IMPORT_MAX_MESSAGES_PER_SESSION, _IMPORT_MAX_TOTAL_MESSAGES = 500, 10_000, 50_000
|
|
_IMPORT_MAX_SESSION_BYTES, _IMPORT_MAX_TOTAL_BYTES = 5 * 1024 * 1024, 25 * 1024 * 1024
|
|
# Accounting workers retire when idle so a bound-method target can't keep an abandoned SessionDB alive.
|
|
_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 = hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()
|
|
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 _close_conn_logged(self, conn, label: str) -> None:
|
|
"""Close *conn*; a failing close leaks a tracked fd: logged at WARNING, never swallowed."""
|
|
try:
|
|
conn.close()
|
|
except Exception as exc:
|
|
logger.warning("%s close failed for %s: %s", label, self.db_path, exc)
|
|
|
|
def __init__(self, db_path: Path = None, read_only: bool = False):
|
|
self.db_path = db_path or _default_db_path()
|
|
_ensure_test_isolation(self.db_path) # before any connection/pragma/mkdir
|
|
self.read_only = read_only
|
|
self._lock = threading.Lock()
|
|
# Read-path split (WAL only): reads borrow from a BOUNDED read-only pool so they
|
|
# never queue behind writer flushes on self._lock (see _read_ctx); unbounded
|
|
# per-thread connections pinned fds for the process lifetime and hit EMFILE.
|
|
self._read_pool: "queue.LifoQueue[sqlite3.Connection]" = queue.LifoQueue(maxsize=_READ_POOL_MAX)
|
|
# Permits bound PEAK descriptors (the pool bounds only the idle set), shared per
|
|
# DATABASE PATH; acquired non-blocking so a permitless reader degrades to the writer lock.
|
|
# One permit per live read connection, held from before the open in _get_read_conn() until after the
|
|
# close in _close_read_conn(). 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)
|
|
self._read_permits = self._read_budget.permits
|
|
self._read_conns_lock = threading.Lock()
|
|
# Set when close() begins; an in-flight reader then closes its own connection
|
|
# instead of re-populating a pool nobody will drain again.
|
|
self._read_conns_closed = False
|
|
# Read-open failure backoff is a TIMESTAMP, not a sticky bool: the likeliest trigger
|
|
# is transient EMFILE, and a permanent flag would demote every reader forever.
|
|
self._read_open_failed_at = 0.0
|
|
self._wal_active, self._write_count = False, 0
|
|
# File identity of the opened state.db, compared on every write so an out-of-band
|
|
# replace cannot limp through in-place surgery (inode: mv/new-file; application_id: cp).
|
|
self._db_file_identity: Optional[tuple] = None
|
|
self._db_file_application_id: int = 0
|
|
self._db_sidecar_identity: Dict[str, tuple] = {}
|
|
self._db_replaced = self._db_wal_generation_lost = False
|
|
# Durable capture of a lost WAL generation (see _capture_retired_generation): once per handle.
|
|
self._retired_generation_capture: Optional[Path] = None
|
|
self._retired_capture_lock = threading.Lock()
|
|
self._retire_connection: Optional[Callable[[Any], None]] = None
|
|
self._connection_pinned = False # one unmatched C reference taken at most once per handle
|
|
self._db_corrupt, self._db_corrupt_reason = False, "" # sticky quarantine (StateDbCorruptError)
|
|
self._fts_usermerge_floor_applied = False # one-shot usermerge-floor write guard
|
|
self._fts_enabled = self._fts_stale = self._trigram_available = False
|
|
# _fts_cjk_loaded: tokenizer on the writer connection; _fts_cjk_available: messages_fts_cjk
|
|
# is queryable AND not marked stale.
|
|
self._fts_cjk_loaded = self._fts_cjk_available = self._fts_unavailable_warned = False
|
|
self._conn = None
|
|
# Async token accounting; distinct from self._lock so enqueue/flush never contends with 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 = self._token_writer_busy = False
|
|
self._token_atexit_hook: Optional[Callable[[], None]] = None
|
|
# Opened via hermes_state_registry.acquire(): close() releases a refcount instead.
|
|
# Set True when this instance is opened via hermes_state_registry.acquire(). 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:
|
|
self._open_read_only()
|
|
else:
|
|
# Where SQLite's close-time checkpoint cannot be switched off, a lost-generation handle
|
|
# is retired unclosed (see close()). Resolve that capability before opening a writer:
|
|
# late cleanup must not import ctypes or look up it in a cleared module dictionary.
|
|
if not _close_time_checkpoint_configurable():
|
|
self._retire_connection = _prepare_connection_retirement()
|
|
self._open_writer()
|
|
self._record_db_file_identity()
|
|
initialization_complete = True
|
|
except Exception as exc:
|
|
# Surface WHY via /resume and friends; callers keep their ``_session_db = None`` path.
|
|
_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)
|
|
|
|
def _open_writer(self) -> None:
|
|
"""Writable open: preflight, zero-byte quarantine, connect + schema (one in-place repair of a
|
|
malformed sqlite_master), generation stamp."""
|
|
# Never materialize a deleted/archived named profile's home: a multiplexer or Desktop backend
|
|
# still holding the profile's route would otherwise re-scaffold it on the next turn (#94590).
|
|
mkdir_under_hermes_home(self.db_path.parent)
|
|
# Read-only file/sidecar preflight BEFORE the first connection: an actionable message
|
|
# instead of an opaque "attempt to write a readonly database" from inside _init_schema.
|
|
preflight_db_writability(self.db_path, db_label="state.db")
|
|
try:
|
|
# Serialize zero-byte check, quarantine, connect and schema commit so concurrent
|
|
# openers don't race the absent-path -> schema-commit window.
|
|
if not self.db_path.exists() or has_invalid_sqlite_header_preopen(self.db_path):
|
|
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,
|
|
)
|
|
self._handle_quarantine_if_invalid(already_locked=lock_acquired)
|
|
self._connect_and_init_with_lock_patience()
|
|
else:
|
|
self._handle_quarantine_if_invalid(already_locked=False)
|
|
self._connect_and_init_with_lock_patience()
|
|
except sqlite3.DatabaseError as exc:
|
|
# A malformed schema fails on the very first statement (before _init_schema), so the
|
|
# FTS-rebuild layer never sees it: repair sqlite_master in place (backup first), reopen once.
|
|
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,
|
|
)
|
|
self._close_connection_quietly(self._conn)
|
|
if not repair_state_db_schema(self.db_path).get("repaired"):
|
|
raise
|
|
self._connect_and_init_with_lock_patience()
|
|
# FTS optimization is OPT-IN (`hermes db optimize`); no background worker races session lifecycle.
|
|
self._ensure_db_file_generation()
|
|
|
|
def _open_read_only(self) -> None:
|
|
"""Read-only attach for cross-profile aggregation: no schema init, NO write
|
|
lock (sidebar polling never contends with that profile's backend); the DB
|
|
must exist. FTS flags are probed with SELECTs only, and the connection is
|
|
closed on ANY probe failure (malformed schema raises DatabaseError) so a
|
|
leaked tracked connection cannot block the forensic backup the writable heal takes next."""
|
|
for attempt in range(_READ_ONLY_IOERR_RETRY_ATTEMPTS + 1):
|
|
try:
|
|
self._conn = conn = self._connect_read_only(timeout=1.0)
|
|
try:
|
|
apply_database_pragmas(conn, db_label="state.db")
|
|
cursor = 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:
|
|
self._conn = None
|
|
self._close_connection_quietly(conn)
|
|
raise
|
|
return
|
|
except sqlite3.OperationalError as ioerr:
|
|
# In-flight WAL checkpoint/reset/frame-flush on the writer side can surface
|
|
# SQLITE_IOERR to a mode=ro reader (it can't do the -shm recovery the read
|
|
# needs). Closes in milliseconds: retry a bounded number of times before
|
|
# classifying the store as failed (#100436; see _READ_ONLY_IOERR_RETRY_ATTEMPTS).
|
|
transient = _DISK_IO_ERROR_MARKER in str(ioerr).lower()
|
|
if attempt >= _READ_ONLY_IOERR_RETRY_ATTEMPTS or not transient:
|
|
raise
|
|
time.sleep(_READ_ONLY_IOERR_RETRY_BACKOFF_S)
|
|
|
|
def _connect_read_only(self, timeout: float) -> sqlite3.Connection:
|
|
"""``mode=ro`` tracked connection with Row factory. check_same_thread=False: pooled connections
|
|
are borrowed by whichever thread reads next; exclusive ownership is enforced by pool checkout."""
|
|
conn = _connect_tracked_db(
|
|
f"file:{self.db_path}?mode=ro", tracking_path=self.db_path, uri=True,
|
|
check_same_thread=False, timeout=timeout, isolation_level=None,
|
|
)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _handle_quarantine_if_invalid(self, already_locked: bool = False) -> None:
|
|
"""Quarantine a zero-byte/headerless state.db so a fresh one can open; if quarantine failed,
|
|
raise the clear message instead of opening the zeroed file."""
|
|
if not (self.db_path.exists() and has_invalid_sqlite_header_preopen(self.db_path)):
|
|
return
|
|
try:
|
|
zsize = self.db_path.stat().st_size
|
|
except OSError:
|
|
zsize = -1
|
|
qpath = quarantine_invalid_state_db(self.db_path, already_locked=already_locked)
|
|
msg = (
|
|
f"state.db has no SQLite header ({zsize} bytes). "
|
|
f"Preserved at {qpath or '(quarantine failed — file left in place)'}. "
|
|
f"Restore from {self.db_path.parent / 'state-snapshots'} via `hermes snapshot list` / "
|
|
f"`hermes snapshot restore <id>` if available, or salvage the preserved bytes with "
|
|
f"`hermes sessions recover --source {qpath or self.db_path}`. "
|
|
"Opening a fresh empty database so the agent can start."
|
|
)
|
|
logger.error(msg)
|
|
_set_last_init_error(msg)
|
|
if qpath is None and self.db_path.exists() and has_invalid_sqlite_header_preopen(self.db_path):
|
|
raise sqlite3.DatabaseError(msg)
|
|
|
|
def _open_writer_conn(self) -> sqlite3.Connection:
|
|
"""Connect + WAL/pragma/tokenizer setup for a writer connection (no schema init). Short timeout:
|
|
jittered application-level retry handles contention, not SQLite's busy handler;
|
|
isolation_level=None: explicit BEGIN IMMEDIATE."""
|
|
conn = _connect_tracked_db(
|
|
str(self.db_path), check_same_thread=False, timeout=1.0, isolation_level=None,
|
|
)
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
mode = apply_wal_with_fallback(conn, db_label="state.db")
|
|
# "wal" is also the *assumed* mode when the on-disk probe was blocked by a concurrent opener
|
|
# (#86515): the lock-free mode=ro read pool needs a confirmed WAL header, so confirm it here.
|
|
# Unknown -> reads queue on the writer lock (slow but correct) instead of racing SQLITE_BUSY
|
|
# on a file that may really be in rollback-journal mode.
|
|
self._wal_active = mode == "wal" and _on_disk_journal_mode(conn) == "wal"
|
|
# Existing WAL/SHM files may predate the main-file hardening;
|
|
# normalize any sidecars that became visible during WAL setup.
|
|
_secure_state_db_files(self.db_path)
|
|
apply_database_pragmas(conn, db_label="state.db")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
self._fts_cjk_loaded = load_fts5_cjk_extension(conn)
|
|
except BaseException:
|
|
self._close_connection_quietly(conn)
|
|
raise
|
|
return conn
|
|
|
|
def _connect_and_init(self) -> None:
|
|
# 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)
|
|
# Create/tighten the main database before sqlite3.connect() so a
|
|
# permissive process umask can never expose a fresh profile store.
|
|
_secure_state_db_files(self.db_path, create_main=True)
|
|
self._conn = self._open_writer_conn()
|
|
self._init_schema()
|
|
|
|
def _connect_and_init_with_lock_patience(self) -> None:
|
|
"""Open + init, waiting out a sibling's write lock with jittered patience:
|
|
_init_schema's DDL runs on a 1s-timeout connection, so a sibling's VACUUM
|
|
or checkpoint used to fail the ENTIRE open and callers disabled
|
|
persistence for the whole run. Non-lock errors propagate immediately."""
|
|
# 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.
|
|
deadline = time.monotonic() + self._WRITE_PATIENCE_S
|
|
while True:
|
|
try:
|
|
self._connect_and_init()
|
|
return
|
|
except sqlite3.OperationalError as exc:
|
|
err = str(exc).lower()
|
|
if "locked" not in err and "busy" not in err:
|
|
raise
|
|
self._close_connection_quietly(self._conn)
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
raise
|
|
jitter = random.uniform(self._WRITE_RETRY_SLOW_MIN_S, self._WRITE_RETRY_SLOW_MAX_S)
|
|
time.sleep(min(jitter, max(deadline - now, 0.001)))
|
|
|
|
# ── Read-path split ──
|
|
|
|
def _get_read_conn(self) -> Optional[sqlite3.Connection]:
|
|
"""Open a fresh read-only connection, or None when unavailable (callers
|
|
return it to self._read_pool). WAL only: WAL readers never block on the
|
|
writer, so reads skip self._lock; under DELETE journal mode (NFS fallback)
|
|
readers hit SQLITE_BUSY storms, so the legacy locked path stays. Autocommit
|
|
reads see everything committed so far (read-your-writes for flush-then-search)."""
|
|
if not self._wal_active or self.read_only:
|
|
return None
|
|
with self._read_conns_lock:
|
|
failed_at = self._read_open_failed_at
|
|
backing_off = failed_at and time.monotonic() - failed_at < _READ_OPEN_RETRY_SECONDS
|
|
if self._read_conns_closed or backing_off:
|
|
return None
|
|
# Permit BEFORE the open: openers race for permits, not descriptors.
|
|
if not self._read_budget.acquire(self):
|
|
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
|
|
conn = None # bound before the try so the handlers can close a half-open one
|
|
try:
|
|
conn = self._connect_read_only(timeout=5.0)
|
|
apply_database_pragmas(conn, db_label="state.db")
|
|
if self._fts_cjk_loaded: # registers in the connection, not the file: ro is fine
|
|
load_fts5_cjk_extension(conn)
|
|
except BaseException as exc:
|
|
# A half-open connection (open ok, extension load failed) is a live tracked descriptor,
|
|
# the leak shape this pool exists to fix; a stranded permit would shrink the read
|
|
# path by one slot forever. (Not _close_read_conn: callers release their own permit.)
|
|
if conn is not None:
|
|
self._close_conn_logged(conn, "partially-opened read conn")
|
|
self._read_budget.release()
|
|
if not isinstance(exc, sqlite3.Error):
|
|
raise
|
|
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)
|
|
return None
|
|
return conn
|
|
|
|
def _evict_one_idle_read_conn(self) -> bool:
|
|
"""Close one idle pooled connection (a peer on the same file wants its permit); never a live one."""
|
|
try:
|
|
conn = self._read_pool.get_nowait()
|
|
except queue.Empty:
|
|
return False
|
|
self._close_read_conn(conn)
|
|
return True
|
|
|
|
def _close_read_conn(self, conn) -> None:
|
|
"""Close a pooled read connection and release its permit even when the close fails (withholding
|
|
it would narrow the read path forever). Over-releasing the BoundedSemaphore raises ValueError."""
|
|
try:
|
|
self._close_conn_logged(conn, "read-conn")
|
|
finally:
|
|
self._read_budget.release()
|
|
|
|
def _checkout_read_conn(self) -> Optional[sqlite3.Connection]:
|
|
"""Borrow a read connection, opening on a miss; None when the read path is unavailable.
|
|
A pool hit costs no permit (the connection already holds one)."""
|
|
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: a pooled read-only
|
|
connection with NO lock under WAL; otherwise (non-WAL, open failure,
|
|
ceiling reached) the writer connection under self._lock — deliberate
|
|
degradation: slower beats EMFILE, which the 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() drained the pool (or queue.Full: unreachable while
|
|
# permits == maxsize, load-bearing if they drift): surplus.
|
|
self._close_read_conn(conn)
|
|
return
|
|
with self._lock:
|
|
if self._conn is None: # close() raced a still-unwinding reader
|
|
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 after ``close()`` raced a live caller (a teardown owner
|
|
set ``_conn = None`` while a worker still had a transcript flush to land).
|
|
Loud (WARNING) and bounded (only after an explicit close()). Caller holds
|
|
``self._lock``. No _init_schema: no DDL races with siblings during teardown."""
|
|
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: a replaced file would be written through stale WAL/shm
|
|
# assumptions; a quarantined handle must never hand a fresh connection to a damaged file.
|
|
if self._db_corrupt and not (self._db_replaced or self._db_file_was_replaced()):
|
|
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(). "
|
|
)
|
|
self._halt_if_db_generation_changed()
|
|
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:
|
|
self._conn = self._open_writer_conn()
|
|
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 failed: {exc}"
|
|
) from exc
|
|
|
|
def _execute_write(
|
|
self, fn: Callable[[sqlite3.Connection], T], patience_s: Optional[float] = None,
|
|
) -> T:
|
|
"""Run *fn(conn)* inside BEGIN IMMEDIATE with jittered lock retry; commit
|
|
is handled here (callers must not commit). Returns *fn*'s result.
|
|
BEGIN IMMEDIATE takes the WAL write lock up front so contention surfaces
|
|
immediately; on locked/busy the Python lock is released, a jitter slept,
|
|
and the WHOLE callback retried — *fn* must stay idempotent under retry."""
|
|
if patience_s is None:
|
|
patience_s = self._WRITE_PATIENCE_S
|
|
deadline = time.monotonic() + patience_s
|
|
compression_deadline: Optional[float] = None # set on the first compression-busy collision
|
|
# One retry for SQLITE_IOERR raised by BEGIN IMMEDIATE itself (callback not run: nothing
|
|
# replayed). Once fn has started, an IOERR leaves settlement unknown and must propagate.
|
|
# 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
|
|
while True:
|
|
self._raise_if_db_corrupt()
|
|
# NOTE: the replaced/generation live probe runs INSIDE the lock below,
|
|
# not here. close() mutates _conn and _db_sidecar_identity under that
|
|
# same lock, ending the WAL generation (SQLite unlinks the -wal/-shm
|
|
# sidecars on a clean close). A lock-free probe that races close() can
|
|
# observe the mid-teardown state — sidecars already unlinked while
|
|
# _db_sidecar_identity is not yet cleared — and misclassify this
|
|
# process's OWN clean close as an externally deleted generation,
|
|
# raising a sticky DeletedWalGenerationError that permanently refuses
|
|
# later writes (#105567). Inside the lock the probe only ever sees the
|
|
# stable post-close state (identity cleared → adopt / reopen path).
|
|
fn_started = False
|
|
try:
|
|
with self._lock:
|
|
self._raise_if_db_replaced()
|
|
if self._conn is None: # close() raced this writer
|
|
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:
|
|
# Transient (see _COMPRESSION_BUSY_WAIT_S): a steer landing mid-compression must not abort.
|
|
# 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.Error as exc:
|
|
# 'no more rows' is a transient engine error on contended WAL appends (some builds
|
|
# raise it as InterfaceError, a sibling of DatabaseError): retry like locked/busy.
|
|
if _is_no_more_rows(exc) and self._sleep_before_write_retry(deadline, patience_s):
|
|
continue
|
|
err_msg = str(exc).lower()
|
|
if isinstance(exc, sqlite3.OperationalError):
|
|
if "locked" in err_msg or "busy" in err_msg:
|
|
if self._sleep_before_write_retry(deadline, patience_s):
|
|
continue
|
|
# Say what actually happened, not 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 (
|
|
_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)
|
|
):
|
|
# Retry on the SAME connection: close()+reopen would cancel this process's
|
|
# POSIX locks for every sibling (howtocorrupt §2.2).
|
|
ioerr_begin_retried = True
|
|
continue
|
|
raise # non-lock error, callback already ran, or patience exhausted
|
|
if isinstance(exc, sqlite3.DatabaseError):
|
|
# An out-of-band replace surfaces as this same corruption class; in-file repair
|
|
# on a NEW generation amplifies the damage.
|
|
if (
|
|
"not a database" in err_msg or is_malformed_db_error(exc)
|
|
or self._is_fts_write_corruption_error(exc)
|
|
):
|
|
self._raise_if_db_replaced()
|
|
# Corrupt FTS shadow tables fail every write via the sync triggers while canonical
|
|
# rows are intact: detach the derived indexes atomically and retry (never rebuild here).
|
|
if self._enter_fts_fail_open(exc):
|
|
continue
|
|
# What survives both checks is structural damage: quarantine.
|
|
if self._is_structural_corruption_error(exc):
|
|
self._halt_db_corrupt(exc)
|
|
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
|
|
(``SELECT changes()`` when the driver reports 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``."""
|
|
return self._read_retrying_ioerr(lambda conn: 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``."""
|
|
return self._read_retrying_ioerr(lambda conn: conn.execute(sql, params).fetchall())
|
|
|
|
def _read_retrying_ioerr(self, fn: Callable[[sqlite3.Connection], T]) -> T:
|
|
"""Run an idempotent SELECT through ``_read_ctx``, retrying a transient SQLITE_IOERR.
|
|
|
|
A warm ``mode=ro`` pooled reader can hit the same millisecond-wide WAL transition window as a
|
|
read-only OPEN (#100436) when its statement executes or steps: a sibling process's checkpoint /
|
|
WAL reset / frame flush surfaces ``disk I/O error`` because a read-only connection cannot rewrite
|
|
the -shm index (#100871, WSL2 ext4-on-vhdx, multi-process). The window closes on its own, so
|
|
the statement is replayed on the SAME connection within the read-only IOERR budget -- never
|
|
closed and reopened (close() cancels this process's POSIX locks for every sibling connection),
|
|
never quarantined (busy is not broken). A persistent IOERR exhausts the budget and propagates."""
|
|
for attempt in range(_READ_ONLY_IOERR_RETRY_ATTEMPTS + 1):
|
|
try:
|
|
with self._read_ctx() as conn:
|
|
return fn(conn)
|
|
except sqlite3.OperationalError as exc:
|
|
if attempt >= _READ_ONLY_IOERR_RETRY_ATTEMPTS or _DISK_IO_ERROR_MARKER not in str(exc).lower():
|
|
raise
|
|
time.sleep(_READ_ONLY_IOERR_RETRY_BACKOFF_S)
|
|
|
|
def _ensure_db_file_generation(self) -> None:
|
|
"""Mint a once-per-file generation stamp (state_meta + application_id). First opener wins (INSERT
|
|
OR IGNORE); application_id is written only while 0 so racers converge. PASSIVE checkpoint only.
|
|
|
|
See #45383.
|
|
"""
|
|
if self.read_only or self._conn is None:
|
|
return
|
|
token = uuid.uuid4().hex
|
|
try:
|
|
with self._lock:
|
|
# Read first: the stamp is minted once per file, and a no-op INSERT OR IGNORE
|
|
# still takes the write lock — under a sibling's transaction it blocked for the
|
|
# busy timeout and the except below then dropped the token entirely. First
|
|
# opener still wins via INSERT OR IGNORE; racers converge on the re-read.
|
|
row = self._conn.execute(
|
|
"SELECT value FROM state_meta WHERE key = ?", (_STATE_DB_GENERATION_KEY,),
|
|
).fetchone()
|
|
if not (row and row[0]):
|
|
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])
|
|
pragma_row = self._conn.execute("PRAGMA application_id").fetchone()
|
|
current = int(pragma_row[0] or 0) if pragma_row else 0
|
|
if current == 0:
|
|
current = (int(token[:8], 16) & 0x7FFFFFFF) or 1
|
|
self._conn.execute(f"PRAGMA application_id={current}")
|
|
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")
|
|
except sqlite3.Error:
|
|
pragma_row = None
|
|
if pragma_row and pragma_row[0]:
|
|
self._db_file_application_id = int(pragma_row[0])
|
|
|
|
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 and _stat_db_file_identity(self.db_path) != recorded:
|
|
return True
|
|
recorded_app = int(self._db_file_application_id or 0)
|
|
if not recorded_app:
|
|
return False
|
|
# Header 0 = WAL not yet checkpointed, not a replace; a real replacement is nonzero.
|
|
disk_app = _read_sqlite_application_id(self.db_path)
|
|
return bool(disk_app and disk_app != recorded_app)
|
|
|
|
def _wal_generation_was_lost(self) -> bool:
|
|
"""True when the WAL/SHM generation this handle opened is gone. Recorded
|
|
generation: pure stat (no /proc walk on healthy writes). Empty identity
|
|
(WAL appeared after open, or cleared by a clean close()): probe
|
|
/proc/self/fd for deleted sidecars and adopt the current ones once clean."""
|
|
recorded = self._db_sidecar_identity or {}
|
|
base = os.fspath(self.db_path)
|
|
if recorded:
|
|
return any(
|
|
_stat_db_file_identity(Path(base + suffix)) != ident for suffix, ident in recorded.items()
|
|
)
|
|
if not self._wal_active: # no sidecar generation to lose; keep /proc off the hot path
|
|
return False
|
|
if sys.platform.startswith("linux"):
|
|
watched = _watched_sqlite_sidecar_paths(self.db_path)
|
|
try:
|
|
for target, fd_path in _proc_fd_targets(os.getpid()):
|
|
canonical = _canonical_sqlite_path(target)
|
|
if (" (deleted)" in target and canonical in watched
|
|
and _fd_is_truly_unlinked(fd_path, watched[canonical])):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
# Probe clean (or unavailable): adopt the current sidecar generation.
|
|
current_identity = _stat_sqlite_sidecar_identity(self.db_path)
|
|
if current_identity:
|
|
self._db_sidecar_identity = current_identity
|
|
return False
|
|
|
|
def _halt_if_db_generation_changed(self) -> None:
|
|
"""Stop writes (logging once) when the file was replaced or its WAL/SHM generation
|
|
is gone: never run in-file repair on a new generation, never keep committing on a
|
|
split WAL. Both flags are sticky."""
|
|
# 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._db_replaced = True
|
|
self._disable_close_time_checkpoint()
|
|
logger.error(_STATE_DB_REPLACED_MSG)
|
|
raise StateDbReplacedError(_STATE_DB_REPLACED_MSG)
|
|
if self._db_wal_generation_lost or self._wal_generation_was_lost():
|
|
self._db_wal_generation_lost = True
|
|
self._disable_close_time_checkpoint()
|
|
try:
|
|
self._capture_retired_generation("halt")
|
|
except RetiredGenerationCaptureError as exc:
|
|
logger.error(
|
|
"Could not capture the retired WAL generation of %s at halt: %s. close() retries "
|
|
"the capture and refuses to settle without it.", self.db_path, exc,
|
|
)
|
|
logger.error(_DELETED_WAL_GENERATION_MSG)
|
|
raise DeletedWalGenerationError(_DELETED_WAL_GENERATION_MSG)
|
|
|
|
def _capture_retired_generation(self, trigger: str) -> Path:
|
|
"""Durably capture the lost WAL generation this handle still holds open, once per handle.
|
|
|
|
The quarantine keeps the retired frames from being checkpointed under wrong page numbers,
|
|
but they live only in an unlinked inode that dies with this process's last descriptor, and
|
|
the canonical DeletedWalGenerationError remediation is to stop the writers. Capturing at the
|
|
first halt (or at close(), whichever sees the loss first) makes "preserve" outlive the
|
|
process. Raises RetiredGenerationCaptureError; nothing is mutated on failure."""
|
|
with self._retired_capture_lock:
|
|
if self._retired_generation_capture is not None:
|
|
return self._retired_generation_capture
|
|
artifact = capture_retired_wal_generation(
|
|
self.db_path, sidecar_identity=dict(self._db_sidecar_identity or {}), trigger=trigger,
|
|
)
|
|
self._retired_generation_capture = artifact
|
|
logger.warning(
|
|
"Captured the retired WAL generation of %s at %s to %s; read its manifest.json before deciding "
|
|
"whether those frames belong on top of the file now at the path.", self.db_path, trigger, artifact,
|
|
)
|
|
return artifact
|
|
|
|
def _raise_if_db_replaced(self) -> None:
|
|
"""Sticky-flag fast path (no log spam on every write), then the live probe."""
|
|
if self._db_replaced:
|
|
raise StateDbReplacedError(_STATE_DB_REPLACED_MSG)
|
|
if self._db_wal_generation_lost:
|
|
raise DeletedWalGenerationError(_DELETED_WAL_GENERATION_MSG)
|
|
self._halt_if_db_generation_changed()
|
|
|
|
@classmethod
|
|
def _is_structural_corruption_error(cls, exc: BaseException) -> bool:
|
|
"""Bare SQLITE_CORRUPT/NOTADB with no FTS provenance: canonical B-tree/schema/freelist damage,
|
|
never repairable from the live write path."""
|
|
return (
|
|
isinstance(exc, sqlite3.DatabaseError)
|
|
and not isinstance(exc, StateDbCorruptError)
|
|
and not cls._is_fts_write_corruption_error(exc)
|
|
and 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"):
|
|
if getattr(exc, attr, None) is not None:
|
|
setattr(err, attr, getattr(exc, attr))
|
|
raise err from exc
|
|
|
|
def _disable_close_time_checkpoint(self) -> bool:
|
|
"""Best-effort SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE (Python 3.12+): sqlite3's
|
|
close() otherwise runs the internal last-connection checkpoint that wrote
|
|
the incident's pages under wrong page numbers (see StateDbCorruptError and
|
|
the generation-loss halts).
|
|
<3.12 has no setconfig, so a lost-generation handle is retired unclosed
|
|
instead (see close()): closing its last descriptor could both run that
|
|
checkpoint and discard committed data present only in an unlinked WAL."""
|
|
flag = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
|
|
conn = self._conn
|
|
setconfig = getattr(conn, "setconfig", None)
|
|
if flag is None or setconfig is None:
|
|
# Same predicate as _close_time_checkpoint_configurable() plus the per-instance
|
|
# getattr: __init__ binds no retirement capability when either half is missing,
|
|
# and close() must agree with that decision or the lost handle would neither
|
|
# setconfig nor pin.
|
|
return False
|
|
try:
|
|
setconfig(flag, True)
|
|
except Exception:
|
|
# No retention capability is bound on this runtime, so close() will let SQLite run the
|
|
# checkpoint over the newer generation: say so where an operator can see it.
|
|
logger.error(
|
|
"Could not disable SQLite's close-time checkpoint on the quarantined handle for %s; "
|
|
"closing it may checkpoint retired frames over the newer generation.",
|
|
self.db_path, exc_info=True,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def _pin_connection(self, conn) -> None:
|
|
"""Retain the exact quarantined connection past GC and interpreter teardown (once per handle).
|
|
|
|
Takes the connection as a parameter: callers are lock-held close paths, and the
|
|
writer-conn thread-safety audit flags self._conn in functions outside `with self._lock`."""
|
|
if not self._connection_pinned:
|
|
self._retire_connection(conn)
|
|
self._connection_pinned = True
|
|
|
|
def _settle_lost_generation_locked(self) -> bool:
|
|
"""Capture the retired generation; return whether the handle must be retired unclosed.
|
|
|
|
Where SQLite's close-time checkpoint cannot be switched off (no setconfig, Python < 3.12),
|
|
sqlite3_close would write the retired frames over the newer generation, so the exact
|
|
connection is retired unclosed instead. A failed capture leaves the handle open for a retry
|
|
-- but the pin is taken FIRST on such a runtime: every production caller reaches close()
|
|
through hermes_state_registry.release_or_close, which swallows the error, so an interpreter
|
|
exit before the retry must not be able to checkpoint the stale frames either."""
|
|
self._db_wal_generation_lost = True
|
|
retire_without_close = not self._disable_close_time_checkpoint() and self._retire_connection is not None
|
|
try:
|
|
artifact = self._capture_retired_generation("close")
|
|
except RetiredGenerationCaptureError as exc:
|
|
if retire_without_close:
|
|
self._pin_connection(self._conn)
|
|
logger.error(
|
|
"Could not capture the retired WAL generation of %s at close: %s. The handle stays open "
|
|
"and close() retries the capture; those frames are NOT yet preserved.", self.db_path, exc,
|
|
)
|
|
raise
|
|
logger.warning(
|
|
"Skipping the close-time WAL checkpoint for %s: this handle's WAL/SHM generation "
|
|
"was deleted or replaced; the retired generation is captured at %s. Stop the other "
|
|
"writers before reopening and inspect the capture before deciding its disposition.",
|
|
self.db_path, artifact,
|
|
)
|
|
if retire_without_close:
|
|
logger.warning(
|
|
"Retaining the quarantined connection for %s unclosed: this runtime cannot "
|
|
"switch off SQLite's close-time checkpoint.", self.db_path,
|
|
)
|
|
return retire_without_close
|
|
|
|
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 budget allows; True = retry, False = deadline passed. Small
|
|
jitter for the first _WRITE_RETRY_SLOW_AFTER_S, then slow; never overshoots the deadline."""
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
return False
|
|
slow = now - (deadline - patience_s) >= self._WRITE_RETRY_SLOW_AFTER_S
|
|
jitter = random.uniform(*(
|
|
(self._WRITE_RETRY_SLOW_MIN_S, self._WRITE_RETRY_SLOW_MAX_S) if slow
|
|
else (self._WRITE_RETRY_MIN_S, self._WRITE_RETRY_MAX_S)
|
|
))
|
|
time.sleep(min(jitter, max(deadline - now, 0.001)))
|
|
return True
|
|
|
|
def _foreign_state_db_holders(self) -> List[Tuple[int, str]]:
|
|
"""Foreign processes holding this DB or its WAL sidecars (see hermes_state_holders)."""
|
|
return _foreign_state_db_holders(self.db_path)
|
|
|
|
def _quarantine_reason(self) -> Optional[str]:
|
|
"""Why this handle must not checkpoint or run in-file repair, or None. A corrupted image has
|
|
torn B-trees; a replaced file or a deleted/replaced WAL generation would checkpoint under
|
|
wrong page numbers into the main DB -- the shutdown-time cause of #105670. Precedence note:
|
|
close() evaluates generation loss BEFORE calling this (and skips it entirely when lost —
|
|
a lost generation settles through the capture path, not the quarantine advisory), while
|
|
the halt path checks replaced first."""
|
|
if self._db_corrupt:
|
|
return f"structural corruption ({self._db_corrupt_reason})"
|
|
if self._db_replaced:
|
|
return "a replaced state.db file"
|
|
if self._db_wal_generation_lost:
|
|
return "a deleted WAL generation (split-brain)"
|
|
return None
|
|
|
|
def _try_wal_checkpoint(self) -> None:
|
|
"""Best-effort PASSIVE WAL checkpoint; never raises. PASSIVE never blocks writers;
|
|
TRUNCATE corrupted B-trees on 65K+ page databases under exclusive-lock I/O pressure.
|
|
|
|
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._quarantine_reason() is not None:
|
|
return
|
|
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":
|
|
"""``with SessionDB(path) as db:`` closes on exit; owners must release deterministically.
|
|
|
|
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``).
|
|
"""
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
self.close()
|
|
return False # never suppress the caller's exception
|
|
|
|
def close(self):
|
|
"""Drain queued token deltas, then a PASSIVE checkpoint on writable handles
|
|
(NOT TRUNCATE: a full WAL reset races the gateway's live writer, tearing
|
|
B-tree pages). A registry-shared instance RELEASES one refcount instead.
|
|
|
|
Drains queued token deltas first (the background writer needs the connection). Read-only connections
|
|
never request a checkpoint. See #45383.
|
|
When this instance is shared (opened via ``hermes_state_registry.acquire``), ``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 self._shared_registry_owned:
|
|
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)
|
|
# Closed flag first: an in-flight reader then closes its own connection.
|
|
with self._read_conns_lock:
|
|
self._read_conns_closed = True
|
|
while self._evict_one_idle_read_conn():
|
|
pass
|
|
with self._lock:
|
|
if self._conn:
|
|
generation_lost = not self.read_only and (
|
|
self._db_wal_generation_lost
|
|
or (bool(self._db_sidecar_identity) and self._wal_generation_was_lost())
|
|
)
|
|
# Loss is settled here, not at exit: the unlinked WAL inode dies with this process's
|
|
# last descriptor (the capture raises and the handle stays open when it fails).
|
|
retire_without_close = generation_lost and self._settle_lost_generation_locked()
|
|
quarantine_reason = None if generation_lost else self._quarantine_reason()
|
|
if quarantine_reason is not None:
|
|
logger.warning(
|
|
"Skipping the close-time WAL checkpoint for %s: this "
|
|
"handle observed %s. Take a snapshot of state.db, -wal and -shm "
|
|
"before restarting, then run `hermes sessions recover --source %s --inspect-only`.",
|
|
self.db_path, quarantine_reason, self.db_path,
|
|
)
|
|
elif not self.read_only and not generation_lost: # PASSIVE, not TRUNCATE (see docstring)
|
|
try:
|
|
# 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.
|
|
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
|
except Exception as exc:
|
|
logger.debug("WAL checkpoint (PASSIVE) at close failed: %s", exc)
|
|
if retire_without_close:
|
|
self._pin_connection(self._conn)
|
|
self._conn = None
|
|
else:
|
|
conn, self._conn = self._conn, None
|
|
self._close_connection_quietly(conn)
|
|
# Only a clean close ends the generation; retain the recorded
|
|
# identity when retiring an unsafe handle.
|
|
self._db_sidecar_identity = {}
|
|
|
|
def __del__(self) -> None:
|
|
"""Safety net: close() if the caller forgot. Attribute access stays
|
|
guarded: module teardown order is undefined."""
|
|
if self.__dict__.get("_conn") is not None:
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Async token accounting (SessionUsageMixin) ──
|
|
# queue_token_counts() is a deque append; a single-writer thread applies deltas in
|
|
# order, coalescing consecutive deltas whose route fields are EQUAL (so the merged
|
|
# UPDATE equals applying them sequentially). Exact readers call flush_token_counts().
|
|
_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",
|
|
)
|
|
|
|
MAX_TITLE_LENGTH = 100
|
|
|
|
# Title provenance, lowest to highest authority: auto-titling may only replace a
|
|
# strictly lower-authority title (``derived`` -> ``llm`` once; never a user-typed name).
|
|
TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM, TITLE_SOURCE_USER = "derived", "llm", "user"
|
|
_TITLE_SOURCE_RANK = {TITLE_SOURCE_DERIVED: 0, TITLE_SOURCE_LLM: 1, TITLE_SOURCE_USER: 2}
|
|
|
|
# Bot Mode's canonical chat is resolved by exact-title lookup: the title IS the identity,
|
|
# so _set_session_title refuses renames of a hidden row holding it.
|
|
# 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). See
|
|
# #92473.
|
|
CANONICAL_BOT_CHAT_TITLE = "Bot Chat"
|
|
|
|
# ── Message storage constants (SessionMessagesMixin) ──
|
|
# Prefix marking JSON-encoded structured content; NUL cannot collide with text.
|
|
_CONTENT_JSON_PREFIX = "\x00json:"
|
|
#: Reactions live inside ``display_metadata`` so they survive row rewrites.
|
|
REACTIONS_METADATA_KEY = "reactions"
|
|
# Columns every conversation projection decodes; ``active`` rides along so a display read
|
|
# can split compaction-archived rows 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"
|
|
)
|
|
|
|
# ── Meta key/value (scheduler bookkeeping) ──
|
|
|
|
def get_meta(self, key: str) -> Optional[str]:
|
|
"""Read state_meta[key] on self._lock (not _read_ctx): fts_rebuild_step reads progress before its
|
|
write transaction and a WAL reader would not see it."""
|
|
with self._lock:
|
|
row = self._conn.execute("SELECT value FROM state_meta WHERE key = ?", (key,)).fetchone()
|
|
return None if row is None else row[0]
|
|
|
|
def set_meta(self, key: str, value: str, *, cursor: Optional[sqlite3.Cursor] = None) -> None:
|
|
"""Upsert state_meta[key]; with ``cursor`` the write is inline (the caller already holds a
|
|
transaction — nesting BEGIN IMMEDIATE would deadlock)."""
|
|
sql = (
|
|
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
)
|
|
if cursor is not None:
|
|
cursor.execute(sql, (key, value))
|
|
else:
|
|
self._write_sql(sql, (key, value))
|
|
|
|
def retag_kanban_worker_sessions(self, workspaces_root: str) -> int:
|
|
"""Retag legacy kanban worker rows from ``cli`` to ``kanban`` by cwd under the board's workspaces
|
|
root; gated once per root via state_meta. Returns 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) + "/%"),
|
|
)
|
|
# rowcount BEFORE set_meta reuses this cursor for its INSERT.
|
|
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]]:
|
|
"""``[(key, value), ...]`` for state_meta keys starting with the literal
|
|
``prefix`` (LIKE wildcards escaped) — e.g. ``loop:<session_id>`` rows."""
|
|
if not prefix:
|
|
return []
|
|
rows = self._read_all(
|
|
"SELECT key, value FROM state_meta WHERE key LIKE ? ESCAPE '\\'", (_escape_like(prefix) + "%",),
|
|
)
|
|
return [(row[0], row[1]) for row in rows]
|
|
|
|
|
|
class AsyncSessionDB:
|
|
"""Async door onto SessionDB: every call runs via asyncio.to_thread so a blocking SQLite call
|
|
never freezes the event loop (no method returns a live cursor)."""
|
|
|
|
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
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from typing import Set # noqa: F401,E402
|
|
import contextlib # noqa: F401,E402
|
|
import errno # noqa: F401,E402
|
|
import struct # noqa: F401,E402
|
|
import weakref # noqa: F401,E402
|
|
|
|
MAX_SAFE_EXPORT_MESSAGES = 20_000
|
|
|
|
MAX_SAFE_RESUME_MESSAGES = 20_000
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'AUTO_VACUUM_MIN_FREELIST_RATIO': ('hermes_state_common', 'AUTO_VACUUM_MIN_FREELIST_RATIO'),
|
|
'ActivityProvenance': ('agent.session_activity', 'ActivityProvenance'),
|
|
'CompressionSessionBusyError': ('hermes_state_errors', 'CompressionSessionBusyError'),
|
|
'CompressionSessionClosedError': ('hermes_state_errors', 'CompressionSessionClosedError'),
|
|
'DEFERRED_INDEX_SQL': ('hermes_state_common', 'DEFERRED_INDEX_SQL'),
|
|
'FTS_CJK_STALE_KEY': ('hermes_state_common', 'FTS_CJK_STALE_KEY'),
|
|
'FTS_CJK_TABLE_SQL': ('hermes_state_fts', 'FTS_CJK_TABLE_SQL'),
|
|
'FTS_CJK_TRIGGER_SQL': ('hermes_state_fts', 'FTS_CJK_TRIGGER_SQL'),
|
|
'FTS_REBUILD_DEFERRAL_KEY': ('hermes_state_common', 'FTS_REBUILD_DEFERRAL_KEY'),
|
|
'FTS_SQL': ('hermes_state_common', 'FTS_SQL'),
|
|
'FTS_STALE_KEY': ('hermes_state_common', 'FTS_STALE_KEY'),
|
|
'FTS_STORAGE_VERSION': ('hermes_state_common', 'FTS_STORAGE_VERSION'),
|
|
'FTS_TRIGRAM_SQL': ('hermes_state_common', 'FTS_TRIGRAM_SQL'),
|
|
'LEGACY_FTS_SQL': ('hermes_state_common', 'LEGACY_FTS_SQL'),
|
|
'LEGACY_FTS_TRIGRAM_SQL': ('hermes_state_common', 'LEGACY_FTS_TRIGRAM_SQL'),
|
|
'MAX_FTS5_QUERY_CHARS': ('hermes_state_common', 'MAX_FTS5_QUERY_CHARS'),
|
|
'PERSISTENCE_ERROR_CAUSES': ('hermes_state_errors', 'PERSISTENCE_ERROR_CAUSES'),
|
|
'SCHEMA_SQL': ('hermes_state_common', 'SCHEMA_SQL'),
|
|
'SCHEMA_VERSION': ('hermes_state_common', 'SCHEMA_VERSION'),
|
|
'SESSION_STATUS_COMPLETE': ('hermes_state_sessions', 'SESSION_STATUS_COMPLETE'),
|
|
'SESSION_STATUS_EMPTY': ('hermes_state_sessions', 'SESSION_STATUS_EMPTY'),
|
|
'SESSION_STATUS_ERROR': ('hermes_state_sessions', 'SESSION_STATUS_ERROR'),
|
|
'SESSION_STATUS_INTERRUPTED': ('hermes_state_sessions', 'SESSION_STATUS_INTERRUPTED'),
|
|
'SKILL_EXCERPT_JOINT': ('agent.skill_commands', 'SKILL_EXCERPT_JOINT'),
|
|
'SKILL_SCAFFOLD_SQL_LIKE': ('agent.skill_commands', 'SKILL_SCAFFOLD_SQL_LIKE'),
|
|
'SessionTurnLeaseLostError': ('hermes_state_errors', 'SessionTurnLeaseLostError'),
|
|
'WalUnsupportedError': ('hermes_state_wal', 'WalUnsupportedError'),
|
|
'apply_durability_barriers': ('hermes_state_repair', 'apply_durability_barriers'),
|
|
'classify_session_status': ('hermes_state_sessions', 'classify_session_status'),
|
|
'collect_state_db_stats': ('hermes_state_dbfile', 'collect_state_db_stats'),
|
|
'count_db_holders': ('hermes_state_dbfile', 'count_db_holders'),
|
|
'describe_skill_invocation': ('agent.skill_commands', 'describe_skill_invocation'),
|
|
'fts5_cjk_so_path': ('hermes_state_fts', 'fts5_cjk_so_path'),
|
|
'is_advisory_lock_contention': ('hermes_state_common', 'is_advisory_lock_contention'),
|
|
'is_automatic_end_reason': ('hermes_state_common', 'is_automatic_end_reason'),
|
|
'is_disk_full_error': ('hermes_state_errors', 'is_disk_full_error'),
|
|
'is_sqlite_wal_reset_vulnerable': ('hermes_state_wal', 'is_sqlite_wal_reset_vulnerable'),
|
|
'is_transient_sqlite_error': ('hermes_state_errors', 'is_transient_sqlite_error'),
|
|
'iter_deleted_sqlite_sidecar_holders': ('hermes_state_dbfile', 'iter_deleted_sqlite_sidecar_holders'),
|
|
'release_or_close': ('hermes_state_registry', 'release_or_close'),
|
|
'report_startup_progress': ('hermes_startup_watchdog', 'report_startup_progress'),
|
|
'resolve_journal_mode': ('hermes_state_wal', 'resolve_journal_mode'),
|
|
'resolve_synchronous_level': ('hermes_state_wal', 'resolve_synchronous_level'),
|
|
'sanitize_context': ('agent.memory_manager', 'sanitize_context'),
|
|
'sqlite_source_id': ('hermes_state_wal', 'sqlite_source_id'),
|
|
'workspace_key': ('hermes_state_sessions', 'workspace_key'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|