d63e5d8a10
Both blockers from @andrexibiza's review of 28a2d7f0ee. 1. The generation lookup was not in the same identity domain as recovery. latest_conversation_boundary() selected on session_key alone, while _declared_conversation_session() is qualified by (source, session_key). X-Hermes-Session-Key accepts any authenticated caller-supplied string, so an API conversation may legally carry the same key as a Telegram row in one database -- a /new over there rotated this conversation's gwk_ generation while recovery correctly refused to cross the same line, moving the affinity identity out from under a physical identity that had not moved. The boundary read now takes (session_key, source), and the carrier is 'source|key|generation' rather than 'key|generation' -- keying on the string alone would also collapse two same-key conversations from different sources onto one routing key, since this value leaves the process verbatim as OpenRouter's sticky session_id and xAI's x-grok-conv-id. The source comes from the agent's own session row, falling back to the platform the row will be created with before it lands. 2. The declared key's stated lower precedence did not survive settlement. Both handlers let stored_session_id / an explicit body session_id win, then called _bind_declared_conversation() unconditionally. record_gateway_session_peer() does SET session_key = ? across compression ancestors, so a request carrying conversation A's chain plus header key B silently rebound A to B: A could no longer be recovered by its own key, and B recovered A's session. Recording is now gated on the declared key having actually selected or minted the session, on both paths. Behind that gate the bind itself refuses to overwrite a row already bound to a different key, so a future caller cannot reintroduce the same defect by opting in wrongly. test_declaration_outranks_the_lineage_root asserted the pre-qualification contract by comparing a DB-backed agent against a DB-less one; it now makes the stronger statement it was written for -- one declared conversation reached through two different physical ids on the same peer. Refs #96811 Found in review by @andrexibiza, whose analysis located each of these defects and specified what a correct fix had to prove. Co-Authored-By: Andrex Ibiza, MBA <andrexibiza@gmail.com>
16125 lines
718 KiB
Python
16125 lines
718 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 contextlib
|
|
import errno
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import random
|
|
import re
|
|
import sqlite3
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import weakref
|
|
from collections import deque
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from agent.memory_manager import sanitize_context
|
|
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 (
|
|
_DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY,
|
|
)
|
|
from agent.skill_commands import (
|
|
SKILL_EXCERPT_JOINT,
|
|
SKILL_SCAFFOLD_SQL_LIKE,
|
|
describe_skill_invocation,
|
|
)
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_startup_watchdog import report_startup_progress
|
|
from hermes_cli.sqlite_runtime import (
|
|
is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable,
|
|
)
|
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, TypeVar, cast
|
|
|
|
from hermes_state_common import ( # noqa: F401 (re-exported for back-compat)
|
|
_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,
|
|
)
|
|
from hermes_state_portability import SessionPortabilityMixin
|
|
from hermes_state_schema import SessionSchemaMixin
|
|
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
|
|
|
|
# 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."
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WAL-compatibility fallback
|
|
# ---------------------------------------------------------------------------
|
|
# SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
|
|
# byte-range locks that don't reliably work on network filesystems (NFS,
|
|
# SMB/CIFS, some FUSE mounts, WSL1). Upstream documents this explicitly:
|
|
# https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
|
|
#
|
|
# On those filesystems ``PRAGMA journal_mode=WAL`` raises
|
|
# ``sqlite3.OperationalError: locking protocol`` (SQLITE_PROTOCOL). If we
|
|
# propagate that, every feature backed by state.db / kanban.db breaks
|
|
# silently — /resume, /title, /history, /branch, kanban dispatcher, etc.
|
|
#
|
|
# ZFS is a separate case: its COW + mmap semantics can corrupt the WAL
|
|
# shared-memory (-shm) file under concurrent connection bursts, presenting
|
|
# as ``disk I/O error`` rather than ``locking protocol``.
|
|
#
|
|
# Instead, fall back to ``journal_mode=DELETE`` (the pre-WAL default) which
|
|
# works on NFS and ZFS. Concurrency drops — concurrent readers are blocked
|
|
# during a write — but the feature works.
|
|
#
|
|
# Separately, SQLite's WAL-reset bug can corrupt multi-process WAL databases
|
|
# on unfixed library builds (issue #69784). See:
|
|
# https://sqlite.org/wal.html#walresetbug
|
|
# Fixed in 3.51.3+ with backports 3.50.7 and 3.44.6. On vulnerable builds we
|
|
# refuse to *enable* WAL for fresh / non-WAL databases (prefer DELETE). We do
|
|
# NOT live-downgrade an on-disk WAL database — other gateway/cron/worker
|
|
# connections may still hold it open, and flipping journal_mode under them is
|
|
# unsafe (same invariant as the NFS path below).
|
|
_WAL_INCOMPAT_MARKERS = (
|
|
"locking protocol", # SQLITE_PROTOCOL on NFS/SMB
|
|
"not authorized", # Some FUSE mounts block WAL pragma outright
|
|
"disk i/o error", # ZFS SHM corruption under concurrent connections
|
|
)
|
|
|
|
# Upper bound for the write-ahead log. SQLite defaults to -1 (unlimited),
|
|
# which lets state.db-wal keep the high-water mark of the largest-ever
|
|
# transaction forever. See _apply_wal_size_limit().
|
|
_WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
|
|
|
|
# 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()
|
|
|
|
# Paths for which we've already logged a WAL-fallback WARNING. Without
|
|
# this, kanban_db.connect() (called on every kanban operation — see
|
|
# hermes_cli/kanban_db.py for ~30 call sites) would re-log the same
|
|
# filesystem-incompat warning on every connection, filling errors.log.
|
|
_wal_fallback_warned_paths: set[str] = set()
|
|
_wal_fallback_warned_lock = threading.Lock()
|
|
|
|
# Dedup WARNING for the WAL-reset vulnerability fallback (issue #69784).
|
|
_wal_reset_bug_warned_paths: set[str] = set()
|
|
_wal_reset_bug_warned_lock = threading.Lock()
|
|
|
|
# Dedup ERROR for the "configured delete overridden by on-disk WAL" warning.
|
|
_delete_overridden_warned_paths: set[str] = set()
|
|
_delete_overridden_warned_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}."
|
|
|
|
|
|
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
|
|
"""Read the journal mode from the SQLite DB header on disk.
|
|
|
|
Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
|
|
if the value cannot be determined (new DB, or PRAGMA read failed).
|
|
|
|
A PRAGMA read can fail transiently with ``disk i/o error`` on
|
|
virtualized block devices (XFS on cloud hosts). Treating that as
|
|
"mode unknown" pushes callers onto their fail-closed unknown-mode
|
|
branch even though the on-disk mode is perfectly readable a few
|
|
milliseconds later. Retry the read a few times before giving up:
|
|
transient EIO clears, deterministic unsupported-filesystem errors do
|
|
not. ``None`` is still returned on final failure so the caller's
|
|
existing "unknown → refuse to downgrade" logic applies.
|
|
"""
|
|
last_exc: Optional[Exception] = None
|
|
for _ in range(4):
|
|
try:
|
|
row = conn.execute("PRAGMA journal_mode").fetchone()
|
|
except sqlite3.OperationalError as exc:
|
|
last_exc = exc
|
|
if "disk i/o error" not in str(exc).lower():
|
|
return None
|
|
time.sleep(0.05)
|
|
continue
|
|
if row is None:
|
|
return None
|
|
mode = row[0]
|
|
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
|
|
try:
|
|
mode = mode.decode("ascii")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
return str(mode).strip().lower() if mode is not None else None
|
|
if last_exc is not None:
|
|
logger.debug(
|
|
"_on_disk_journal_mode: retries exhausted on disk read (%s)", last_exc
|
|
)
|
|
return None
|
|
|
|
|
|
def _apply_wal_size_limit(conn: sqlite3.Connection) -> None:
|
|
"""Bound the WAL so it returns space to the OS after big transactions.
|
|
|
|
SQLite's default ``journal_size_limit`` is -1 (unlimited): after a
|
|
checkpoint the WAL file is *reused in place* and never truncated, so
|
|
``state.db-wal`` permanently retains the high-water mark of the largest
|
|
transaction ever run against it.
|
|
|
|
A single bulk operation is enough to strand gigabytes. Observed on a
|
|
3.0 GB ``state.db``: ``hermes sessions optimize`` (FTS merge + VACUUM)
|
|
rewrites every page through the WAL, leaving a **3.07 GB**
|
|
``state.db-wal`` sitting next to the database indefinitely — the host
|
|
went from 6.9 GB free to 772 MB (100% full) and stayed there, because
|
|
nothing shrinks the WAL back down. An explicit
|
|
``PRAGMA wal_checkpoint(TRUNCATE)`` reclaimed the full 3.07 GB, which
|
|
confirms the space was pure slack rather than live data.
|
|
|
|
That also makes the maintenance command self-defeating on exactly the
|
|
databases that need it most: the larger the DB, the larger the WAL it
|
|
strands, so ``optimize`` can consume more disk than it frees.
|
|
|
|
``journal_size_limit`` makes SQLite truncate the WAL back to the limit
|
|
at each checkpoint. 64 MiB is comfortably above normal transaction
|
|
sizes (so steady-state commits never pay a truncate) while capping the
|
|
stranded slack at a bounded, predictable figure.
|
|
|
|
``hermes_cli/kanban_db.py`` already bounds its WAL growth with
|
|
``wal_autocheckpoint=100``; the session store — by far the larger
|
|
database — had no equivalent.
|
|
|
|
Best-effort: never raises. A failure here only costs disk slack, and
|
|
must not prevent the database from opening.
|
|
"""
|
|
try:
|
|
conn.execute(f"PRAGMA journal_size_limit={_WAL_SIZE_LIMIT_BYTES}")
|
|
except sqlite3.OperationalError as exc: # pragma: no cover - defensive
|
|
logger.debug("journal_size_limit not applied: %s", exc)
|
|
|
|
|
|
def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
|
|
"""Enable ``PRAGMA checkpoint_fullfsync`` on macOS (no-op elsewhere).
|
|
|
|
On Darwin, ``synchronous=FULL`` (the WAL default) issues a plain
|
|
``fsync()``, which Apple documents does *not* guarantee that data
|
|
has reached stable storage or that writes are not reordered — see
|
|
the ``fsync(2)`` man page. SQLite's WAL corruption-safety guarantee
|
|
assumes the OS honors the fsync write barrier; macOS does not unless
|
|
the app uses ``F_FULLFSYNC``.
|
|
|
|
During a launchd *system* shutdown/reboot the OS page cache is
|
|
dropped (effectively a power-loss event for in-flight pages), so a
|
|
WAL checkpoint whose ``fsync()`` "reported" durable may never have
|
|
hit the platter — corrupting ``state.db`` with a malformed image.
|
|
This is the trigger in issue #30636 ("SIGTERM during launchd
|
|
shutdown under high load"), distinct from a plain in-session kill
|
|
(which the page cache survives and SQLite recovers from).
|
|
|
|
``checkpoint_fullfsync=1`` forces an ``F_FULLFSYNC`` barrier only at
|
|
checkpoint boundaries — where WAL frames land in the main DB — so the
|
|
cost amortizes to roughly +0.1 ms/commit (vs ~+4 ms for the broader
|
|
``fullfsync=1`` that flushes on every commit's WAL sync). Guarded by
|
|
``sys.platform == "darwin"`` because ``F_FULLFSYNC`` is macOS-only;
|
|
on other platforms the PRAGMA is a no-op, so we skip it entirely.
|
|
|
|
Best-effort: never raises.
|
|
"""
|
|
if sys.platform != "darwin":
|
|
return
|
|
try:
|
|
conn.execute("PRAGMA checkpoint_fullfsync=1")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
|
|
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
|
|
"""Enforce ``PRAGMA synchronous=FULL`` on macOS to prevent btree corruption.
|
|
|
|
On Darwin, the default ``synchronous=NORMAL`` only calls ``fsync()``,
|
|
which Apple's fsync(2) man page explicitly states does *not* guarantee
|
|
data-on-platter or write-ordering. During a WAL checkpoint race with
|
|
process termination (e.g., launchd shutdown), this can leave the main
|
|
DB with half-written btree pages → ``btreeInitPage error 11``.
|
|
|
|
WAL mode's durability guarantee assumes the OS honors fsync barriers;
|
|
macOS does not unless we explicitly set ``synchronous=FULL``, which issues
|
|
a real ``fsync()`` on every transaction commit. The ``F_FULLFSYNC``
|
|
barrier at checkpoint boundaries is handled separately by
|
|
:func:`_apply_macos_checkpoint_barrier`.
|
|
|
|
This function is called after any successful WAL activation (either
|
|
from ``apply_wal_with_fallback()`` setting a fresh WAL or when probing
|
|
an existing WAL mode). It ensures macOS connections always use FULL
|
|
synchronous mode, even if a prior connection set ``synchronous=NORMAL``.
|
|
|
|
Best-effort: never raises.
|
|
"""
|
|
if sys.platform != "darwin":
|
|
return
|
|
try:
|
|
conn.execute("PRAGMA synchronous=FULL")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
|
|
def is_sqlite_wal_reset_vulnerable(
|
|
version_info: Optional[tuple] = None,
|
|
) -> bool:
|
|
"""Return True when the linked SQLite library has the WAL-reset bug.
|
|
|
|
Upstream documents the bug in versions 3.7.0 through 3.51.2, fixed in
|
|
3.51.3+, with backports 3.50.7 and 3.44.6:
|
|
https://sqlite.org/wal.html#walresetbug
|
|
|
|
Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe.
|
|
"""
|
|
info = version_info if version_info is not None else sqlite3.sqlite_version_info
|
|
return _is_sqlite_wal_reset_vulnerable(info)
|
|
|
|
|
|
def sqlite_source_id() -> str:
|
|
"""Return ``sqlite_source_id()``, or an empty string when unavailable."""
|
|
try:
|
|
conn = sqlite3.connect(":memory:")
|
|
try:
|
|
row = conn.execute("SELECT sqlite_source_id()").fetchone()
|
|
finally:
|
|
conn.close()
|
|
except sqlite3.Error:
|
|
return ""
|
|
if not row or row[0] is None:
|
|
return ""
|
|
return str(row[0])
|
|
|
|
|
|
def _database_has_content(conn: sqlite3.Connection) -> bool:
|
|
"""Return whether the database file already holds pages.
|
|
|
|
Used to tell an EXISTING database apart from a brand-new one before
|
|
rewriting its journal mode. ``PRAGMA page_count`` is a header read, so
|
|
this costs nothing and takes no lock.
|
|
|
|
Fail-quiet: any error, or a database we cannot measure, answers False.
|
|
The only caller uses this to decide whether to emit a warning, and a
|
|
warning that fires when the answer is unknown would fire on every fresh
|
|
database -- precisely the case where there is provably no operator choice
|
|
being overwritten.
|
|
"""
|
|
try:
|
|
row = conn.execute("PRAGMA page_count").fetchone()
|
|
except sqlite3.Error:
|
|
return False
|
|
if not row or row[0] is None:
|
|
return False
|
|
try:
|
|
return int(row[0]) > 0
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def resolve_journal_mode() -> str:
|
|
"""Return the configured journal mode (``wal`` or ``delete``).
|
|
|
|
``database.journal_mode`` in config.yaml is the canonical operator
|
|
setting. ``wal`` remains the default; use ``delete`` when the backing
|
|
filesystem does not provide WAL-safe durability (for example macOS
|
|
virtiofs, NFS, or SMB). Invalid or malformed values fail safely to the
|
|
existing default.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
|
|
config = load_config_readonly() or {}
|
|
database = config.get("database", {})
|
|
if not isinstance(database, dict):
|
|
return "wal"
|
|
raw = database.get("journal_mode", "wal")
|
|
except Exception:
|
|
return "wal"
|
|
|
|
if not isinstance(raw, str):
|
|
return "wal"
|
|
mode = raw.strip().lower()
|
|
return mode if mode in ("wal", "delete") else "wal"
|
|
|
|
|
|
class WalUnsupportedError(sqlite3.OperationalError):
|
|
"""Raised by :func:`apply_wal_with_fallback` when ``require_wal=True`` and
|
|
the filesystem cannot provide WAL journal mode.
|
|
|
|
Covers both shapes of WAL refusal on network filesystems (NFS / SMB / FUSE
|
|
/ the AgentFS NFS overlay): SQLite *raising* ``SQLITE_PROTOCOL`` ("locking
|
|
protocol"), and the quieter macOS-NFS case where ``PRAGMA journal_mode=WAL``
|
|
silently returns the still-effective mode without raising. Subclasses
|
|
``sqlite3.OperationalError`` so existing ``except sqlite3.OperationalError``
|
|
DB-init handling still catches it, while callers that specifically mandate
|
|
WAL can catch this narrower type.
|
|
"""
|
|
|
|
|
|
def apply_wal_with_fallback(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
db_label: str = "state.db",
|
|
require_wal: bool = False,
|
|
) -> str:
|
|
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
|
|
|
|
Returns the journal mode actually set (``"wal"`` or ``"delete"``).
|
|
|
|
On WAL-incompatible filesystems (NFS, SMB, some FUSE, ZFS), SQLite either
|
|
raises ``OperationalError("locking protocol")`` /
|
|
``OperationalError("disk I/O error")`` or — on macOS NFS / SMB /
|
|
the AgentFS NFS overlay — silently refuses the switch and leaves the DB in
|
|
DELETE. Either way the degradation is logged at ERROR level (it is a real
|
|
loss of concurrency — a write blocks concurrent readers — not a cosmetic
|
|
warning) and, by default, the function falls back to DELETE (the pre-WAL
|
|
default, which works on NFS and ZFS) so the feature keeps working.
|
|
|
|
On SQLite builds that still contain the WAL-reset corruption bug
|
|
(issue #69784), refuse to enable WAL on fresh / non-WAL databases
|
|
(prefer DELETE). If the on-disk DB is already WAL, keep WAL and warn
|
|
— never live-downgrade under possible concurrent openers.
|
|
|
|
This gate (#70055) is deliberately RETAINED. An earlier revision of the
|
|
lock-cancellation fix (#71724) reverted it on the theory that DELETE was
|
|
"the mode that corrupts", but that comparison was confounded: the clean
|
|
WAL result came from SQLite 3.53.1, which carries BOTH the WAL-reset fix
|
|
AND 3.51.0's defenses against close()-broken POSIX locks, so it says
|
|
nothing about 3.50.4. Re-measured on the actually-bundled 3.50.4 with
|
|
the lock fix in place, WAL and DELETE are both clean (0/3 each) — i.e.
|
|
there is no evidence that WAL is safer here, and upstream still documents
|
|
the WAL-reset bug as real through 3.51.2 with serious consequences. Until
|
|
a fixed runtime is delivered, keep new databases out of WAL.
|
|
|
|
Callers that genuinely require WAL concurrency (and would rather fail loudly
|
|
than run silently degraded) pass ``require_wal=True``; the function then
|
|
raises :class:`WalUnsupportedError` instead of returning ``"delete"``. All
|
|
current callers deliberately keep the default ``require_wal=False`` so
|
|
NFS-homed installs keep working.
|
|
|
|
The ERROR is deduplicated per ``db_label``: repeated connections to the
|
|
same underlying DB (e.g. kanban_db.connect() which is called on every
|
|
kanban operation) log once per process, not once per call. Different
|
|
db_labels log independently, so state.db and kanban.db each get one error
|
|
on the same NFS mount.
|
|
|
|
Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
|
|
both databases get identical fallback behavior.
|
|
|
|
Never downgrades to DELETE if the on-disk DB header reports WAL — see
|
|
_on_disk_journal_mode. That holds for both the NFS path and the
|
|
WAL-reset vulnerability path.
|
|
"""
|
|
configured = resolve_journal_mode()
|
|
|
|
# Vulnerable SQLite: do not enable WAL on new/non-WAL files. Resolve the
|
|
# operator setting first so an explicit DELETE request still verifies that
|
|
# SQLite actually accepted DELETE rather than silently returning MEMORY or
|
|
# another connection-specific mode.
|
|
if is_sqlite_wal_reset_vulnerable():
|
|
return _apply_delete_for_wal_reset_bug(
|
|
conn,
|
|
db_label=db_label,
|
|
require_delete=configured == "delete",
|
|
)
|
|
|
|
# Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.
|
|
# Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.
|
|
current_mode = _on_disk_journal_mode(conn)
|
|
if current_mode == "wal":
|
|
if configured == "delete":
|
|
# Never-live-downgrade keeps this WAL; tell the operator their
|
|
# configured delete did not apply (see _log_configured_delete_overridden_once).
|
|
_log_configured_delete_overridden_once(db_label)
|
|
_apply_wal_size_limit(conn)
|
|
_apply_macos_checkpoint_barrier(conn)
|
|
_enforce_macos_synchronous_full(conn)
|
|
return "wal"
|
|
|
|
# #68545: honor the canonical database.journal_mode setting. Existing
|
|
# on-disk WAL databases were returned above and are never live-downgraded.
|
|
if configured == "delete":
|
|
if current_mode is None:
|
|
# The mode probe failed (database locked / busy): another
|
|
# process may hold this DB open in WAL. Ownership is not
|
|
# provably exclusive, so flipping journal modes here could
|
|
# destroy committed-but-uncheckpointed WAL transactions of a
|
|
# concurrent writer. Fail loudly instead of downgrading — the
|
|
# operator explicitly requested DELETE and we cannot verify it.
|
|
raise sqlite3.OperationalError(
|
|
"could not verify journal mode before applying configured "
|
|
"journal_mode=delete (database is locked — possible "
|
|
"concurrent openers); refusing to downgrade a database "
|
|
"this process does not exclusively own"
|
|
)
|
|
actual = _set_journal_mode_no_wait(conn, "DELETE")
|
|
if actual != "delete":
|
|
raise sqlite3.OperationalError(
|
|
f"could not set configured journal_mode=delete (got {actual or 'no result'})"
|
|
)
|
|
return actual
|
|
|
|
# Decide BEFORE the flip whether it would silently overwrite a mode
|
|
# somebody chose. Both inputs are only readable while the file is still
|
|
# in its original state: `current_mode` is the probe above, and
|
|
# page_count distinguishes an existing database from a fresh one.
|
|
#
|
|
# A 0-page database has no prior choice to overwrite, and every caller
|
|
# reaches this before creating any schema (SessionDB._connect_and_init
|
|
# applies WAL, then _init_schema), so brand-new databases land here empty
|
|
# and stay quiet.
|
|
_upgrading_existing_db = (
|
|
current_mode is not None
|
|
and current_mode != "wal"
|
|
and _database_has_content(conn)
|
|
)
|
|
|
|
try:
|
|
# ``PRAGMA journal_mode=WAL`` is a query-that-sets: it RETURNS the
|
|
# resulting journal mode. Network filesystems that refuse WAL by
|
|
# *raising* SQLITE_PROTOCOL ("locking protocol") are handled in the
|
|
# except branch below. But macOS NFS — and SMB/CIFS, and the AgentFS
|
|
# NFS overlay — refuse the switch WITHOUT raising: the pragma simply
|
|
# returns the still-effective mode (e.g. ``delete``). Trust the
|
|
# returned row, not the mere absence of an exception; otherwise we
|
|
# report a false ``"wal"`` AND skip the fallback WARNING, leaving the
|
|
# DB silently in DELETE (reader-blocks-writer) with no signal.
|
|
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
|
mode = str(row[0]).strip().lower() if row and row[0] is not None else ""
|
|
if mode == "wal":
|
|
if _upgrading_existing_db:
|
|
_log_journal_mode_upgrade_once(db_label, current_mode)
|
|
_apply_wal_size_limit(conn)
|
|
_apply_macos_checkpoint_barrier(conn)
|
|
_enforce_macos_synchronous_full(conn)
|
|
return "wal"
|
|
# Silent refusal (macOS NFS / SMB / AgentFS overlay): WAL was not
|
|
# honored, but nothing raised.
|
|
silent_exc = WalUnsupportedError(
|
|
f"journal_mode=WAL refused without raising (still {mode!r})"
|
|
)
|
|
if require_wal:
|
|
raise silent_exc
|
|
_log_wal_fallback_once(db_label, silent_exc)
|
|
return mode or "delete"
|
|
except sqlite3.OperationalError as exc:
|
|
# The require_wal silent-refusal raise above is a WalUnsupportedError
|
|
# (an OperationalError subclass) and lands here — propagate it
|
|
# unchanged rather than re-running it through the marker logic.
|
|
if isinstance(exc, WalUnsupportedError):
|
|
raise
|
|
msg = str(exc).lower()
|
|
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
|
|
# Unrelated OperationalError — don't silently swallow.
|
|
raise
|
|
# ``disk i/o error`` is ambiguous: on ZFS / APFS-CoW it is a
|
|
# deterministic WAL-incompatibility (SHM corruption under concurrent
|
|
# connection bursts — #55305, #71498), but it can also be a one-shot
|
|
# transient EIO (page-cache pressure, brief lock contention).
|
|
# Treating a transient EIO as a permanent downgrade signal produced
|
|
# the mixed-journal-mode corruption pattern fixed in 5c49cd0ed0
|
|
# (process A downgrades to DELETE while sibling processes set WAL).
|
|
# Disambiguate by retrying the pragma a couple of times: transient
|
|
# EIO clears and we return "wal"; the deterministic filesystem cases
|
|
# keep failing and fall through to the guarded DELETE fallback.
|
|
if "disk i/o error" in msg:
|
|
for _ in range(2):
|
|
time.sleep(0.05)
|
|
try:
|
|
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
|
except sqlite3.OperationalError as retry_exc:
|
|
if "disk i/o error" not in str(retry_exc).lower():
|
|
raise
|
|
exc = retry_exc
|
|
continue
|
|
mode = (
|
|
str(row[0]).strip().lower()
|
|
if row and row[0] is not None
|
|
else ""
|
|
)
|
|
if mode == "wal":
|
|
# Same flip, later door: a transient EIO cleared and the
|
|
# switch went through. The header rewrite is identical, so
|
|
# the signal must be too.
|
|
if _upgrading_existing_db:
|
|
_log_journal_mode_upgrade_once(db_label, current_mode)
|
|
_apply_wal_size_limit(conn)
|
|
_apply_macos_checkpoint_barrier(conn)
|
|
_enforce_macos_synchronous_full(conn)
|
|
return "wal"
|
|
break
|
|
# Don't downgrade if another process already set WAL on disk, or if
|
|
# the mode cannot be verified at all (probe blocked by a concurrent
|
|
# opener's locks) — ownership is not provably exclusive either way.
|
|
existing = _on_disk_journal_mode(conn)
|
|
if existing == "wal" or existing is None:
|
|
raise
|
|
if require_wal:
|
|
# Caller mandates WAL — fail loudly instead of degrading to DELETE.
|
|
raise WalUnsupportedError(str(exc)) from exc
|
|
_log_wal_fallback_once(db_label, exc)
|
|
_set_journal_mode_no_wait(conn, "DELETE")
|
|
return "delete"
|
|
|
|
|
|
def _set_journal_mode_no_wait(conn: sqlite3.Connection, mode: str) -> str:
|
|
"""Execute ``PRAGMA journal_mode=<mode>`` without waiting on other openers.
|
|
|
|
This is the ONLY place a journal-mode switch pragma may be issued for a
|
|
non-WAL target. It temporarily forces ``busy_timeout=0`` so SQLite's own
|
|
exclusivity requirement becomes a concurrent-opener detector: leaving WAL
|
|
mode requires exclusive access to the database, so if ANY other connection
|
|
(this process or another) holds the DB open, the pragma fails immediately
|
|
with ``database is locked`` instead of waiting out a busy timeout and
|
|
sneaking the flip in between a concurrent writer's transactions — which is
|
|
exactly how committed-but-uncheckpointed WAL transactions get destroyed.
|
|
|
|
Callers must treat a raised ``OperationalError`` as "not exclusively
|
|
owned: leave the journal mode alone", never as a retryable condition.
|
|
|
|
Returns the resulting journal mode as reported by SQLite (lowercase), or
|
|
``""`` when SQLite returned no row.
|
|
"""
|
|
previous_timeout = 0
|
|
try:
|
|
row = conn.execute("PRAGMA busy_timeout").fetchone()
|
|
if row and row[0] is not None:
|
|
previous_timeout = int(row[0])
|
|
except (sqlite3.OperationalError, TypeError, ValueError):
|
|
previous_timeout = 0
|
|
conn.execute("PRAGMA busy_timeout=0")
|
|
try:
|
|
row = conn.execute(f"PRAGMA journal_mode={mode}").fetchone()
|
|
return str(row[0]).strip().lower() if row and row[0] is not None else ""
|
|
finally:
|
|
try:
|
|
conn.execute(f"PRAGMA busy_timeout={previous_timeout}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
|
|
def _apply_delete_for_wal_reset_bug(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
db_label: str,
|
|
require_delete: bool = False,
|
|
) -> str:
|
|
"""Avoid enabling WAL when the linked SQLite has the WAL-reset bug.
|
|
|
|
- Already-WAL on disk: leave WAL alone (no live downgrade) and warn.
|
|
- Mode unreadable (probe blocked by a concurrent opener's locks):
|
|
ownership is not provably exclusive — leave the journal mode alone
|
|
and warn. Never treat "could not read the mode" as "not WAL": that
|
|
exact confusion let a vulnerable-SQLite process flip a live WAL
|
|
state.db to DELETE under a concurrent WAL writer, destroying its
|
|
committed-but-uncheckpointed transactions.
|
|
- Otherwise: set DELETE (refusing to wait out concurrent openers) and
|
|
warn.
|
|
- For an explicit operator request, verify SQLite accepted DELETE.
|
|
"""
|
|
current = _on_disk_journal_mode(conn)
|
|
|
|
if current == "wal":
|
|
_log_wal_reset_bug_once(db_label, kept_wal=True)
|
|
if require_delete:
|
|
# The vulnerability warning above suggests upgrading SQLite, which
|
|
# does not help on a WAL-incompatible filesystem; surface that the
|
|
# configured delete is not in effect (see _log_configured_delete_overridden_once).
|
|
# Emitted last so the actionable message is the final one in the log.
|
|
_log_configured_delete_overridden_once(db_label)
|
|
# Do not TRUNCATE / journal_mode=DELETE while other processes may
|
|
# still hold this WAL DB open; same safety rule as the NFS path.
|
|
_apply_wal_size_limit(conn)
|
|
_apply_macos_checkpoint_barrier(conn)
|
|
_enforce_macos_synchronous_full(conn)
|
|
return "wal"
|
|
|
|
if current is None:
|
|
# The mode probe itself failed — another opener's locks are the
|
|
# most likely cause, and the DB may well be in WAL under a live
|
|
# writer. Never flip a journal mode we cannot even read.
|
|
if require_delete:
|
|
raise sqlite3.OperationalError(
|
|
"could not verify journal mode before applying configured "
|
|
"journal_mode=delete (database is locked — possible "
|
|
"concurrent openers); refusing to downgrade a database "
|
|
"this process does not exclusively own"
|
|
)
|
|
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
|
|
return "wal"
|
|
|
|
actual = ""
|
|
try:
|
|
actual = _set_journal_mode_no_wait(conn, "DELETE")
|
|
except sqlite3.OperationalError as exc:
|
|
if require_delete:
|
|
raise
|
|
lowered = str(exc).lower()
|
|
if "locked" in lowered or "busy" in lowered:
|
|
# A concurrent opener appeared between the probe and the flip
|
|
# (or already held the DB): SQLite refused the exclusive lock.
|
|
# Leave the journal mode exactly as it is.
|
|
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
|
|
return current or "delete"
|
|
# Best-effort for the automatic vulnerable-runtime fallback: DELETE is
|
|
# normally already the default for new file-backed databases.
|
|
if require_delete and actual != "delete":
|
|
raise sqlite3.OperationalError(
|
|
"could not set configured journal_mode=delete "
|
|
f"(got {actual or 'no result'})"
|
|
)
|
|
_log_wal_reset_bug_once(db_label, kept_wal=False)
|
|
return "delete"
|
|
|
|
|
|
def _wal_reset_repair_hint() -> str:
|
|
"""Return a context-appropriate hint for repairing the SQLite runtime.
|
|
|
|
Uses the codebase's install-type detection so the hint matches what
|
|
``hermes update`` can actually do for this install (#75153).
|
|
"""
|
|
try:
|
|
from hermes_cli.config import (
|
|
detect_install_method,
|
|
recommended_update_command_for_method,
|
|
get_project_root,
|
|
)
|
|
method = detect_install_method(get_project_root())
|
|
cmd = recommended_update_command_for_method(method)
|
|
if method in {"git", "unknown"}:
|
|
return f"Hermes-managed installs can repair the embedded runtime with `{cmd}`"
|
|
if method == "docker":
|
|
return f"update the container image with `{cmd}`"
|
|
# nix/nixos
|
|
return cmd
|
|
except Exception:
|
|
pass
|
|
return (
|
|
"install a Python build bundled with SQLite 3.51.3+ "
|
|
"(or backports 3.50.7 / 3.44.6) and restart Hermes"
|
|
)
|
|
|
|
|
|
# Dedup state for _log_journal_mode_upgrade_once, mirroring the
|
|
# _wal_fallback_warned_* pair below it.
|
|
_journal_upgrade_warned_paths: set = set()
|
|
_journal_upgrade_warned_lock = threading.Lock()
|
|
|
|
|
|
def _log_wal_reset_bug_once(
|
|
db_label: str,
|
|
*,
|
|
kept_wal: bool,
|
|
indeterminate: bool = False,
|
|
) -> None:
|
|
"""Log once per (process, db_label) about the WAL-reset vulnerability path."""
|
|
with _wal_reset_bug_warned_lock:
|
|
if db_label in _wal_reset_bug_warned_paths:
|
|
return
|
|
_wal_reset_bug_warned_paths.add(db_label)
|
|
if indeterminate:
|
|
action = (
|
|
"journal mode could not be verified or exclusively switched "
|
|
"(database is locked — possible concurrent openers); leaving the "
|
|
"journal mode untouched (no live downgrade under concurrent "
|
|
"openers)"
|
|
)
|
|
elif kept_wal:
|
|
action = (
|
|
"is already in WAL mode — leaving WAL in place (no live "
|
|
"downgrade under concurrent openers)"
|
|
)
|
|
else:
|
|
action = "using journal_mode=DELETE instead of enabling WAL"
|
|
# Check whether this is a Hermes-managed install (uv-managed venv)
|
|
# so the warning doesn't promise a repair path that doesn't exist
|
|
# for git/pip/system Python installs (#75153).
|
|
repair_hint = _wal_reset_repair_hint()
|
|
logger.warning(
|
|
"%s: linked SQLite %s is vulnerable to the WAL-reset corruption "
|
|
"bug (https://sqlite.org/wal.html#walresetbug) — %s. "
|
|
"Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); "
|
|
"%s. See `hermes doctor`. This warning fires once per "
|
|
"process per database.",
|
|
db_label,
|
|
sqlite3.sqlite_version,
|
|
action,
|
|
repair_hint,
|
|
)
|
|
|
|
|
|
def _log_journal_mode_upgrade_once(db_label: str, previous_mode: str) -> None:
|
|
"""Log a single WARNING per (process, db_label) about a non-WAL -> WAL flip.
|
|
|
|
``PRAGMA journal_mode`` is a property of the FILE, not of the connection:
|
|
switching an existing database to WAL rewrites its header and outlives the
|
|
process that did it. Operators do set it directly on the file -- that was
|
|
the documented mitigation for the SQLite 3.50.4 WAL-reset bug -- and
|
|
nothing here told them the next open would silently put it back.
|
|
|
|
WARNING, not ERROR, and deliberately so. The reverse move is logged at
|
|
ERROR by ``_log_wal_fallback_once`` because dropping to DELETE is a real
|
|
loss of concurrency; this direction is normally the desirable one (see
|
|
``hermes_cli/managed_uv._default_live_venv``, which treats a database
|
|
stuck on DELETE as a bug worth repairing on update). The problem is not
|
|
the change, it is that the change was invisible: an operator who chose
|
|
DELETE deliberately had no way to learn their choice had been overwritten,
|
|
or which lever makes it stick. So this says what happened and names the
|
|
durable setting, without claiming a degradation that is not there.
|
|
|
|
Deduped per process per ``db_label`` like its siblings: kanban opens a
|
|
fresh connection per operation, so an undeduped line here would be a log
|
|
flood rather than a signal.
|
|
"""
|
|
with _journal_upgrade_warned_lock:
|
|
if db_label in _journal_upgrade_warned_paths:
|
|
return
|
|
_journal_upgrade_warned_paths.add(db_label)
|
|
logger.warning(
|
|
"%s: on-disk journal_mode was %s and has been switched to WAL. This "
|
|
"rewrites the database header and persists after this process exits. "
|
|
"If %s was a deliberate choice (for example the mitigation for the "
|
|
"SQLite WAL-reset bug, or a WAL-unsafe filesystem), setting it with "
|
|
"PRAGMA on the file will not survive -- every open re-applies the "
|
|
"configured mode. Set `database.journal_mode: delete` in config.yaml "
|
|
"to make it stick. This message fires once per process per database.",
|
|
db_label,
|
|
previous_mode,
|
|
previous_mode,
|
|
)
|
|
|
|
|
|
def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
|
|
"""Log a single ERROR per (process, db_label) about WAL fallback.
|
|
|
|
ERROR (not WARNING): a DB silently dropped to DELETE means a real loss of
|
|
concurrency — under the kanban dispatcher + workers a write blocks readers,
|
|
surfacing as SQLITE_BUSY/lock contention — so it must be loud, not cosmetic.
|
|
|
|
Without this dedup, NFS users running kanban (which opens a fresh
|
|
connection on every operation — see hermes_cli/kanban_db.py) would
|
|
fill errors.log with hundreds of identical errors per hour.
|
|
"""
|
|
with _wal_fallback_warned_lock:
|
|
if db_label in _wal_fallback_warned_paths:
|
|
return
|
|
_wal_fallback_warned_paths.add(db_label)
|
|
logger.error(
|
|
"%s: WAL journal_mode unsupported on this filesystem (%s) — "
|
|
"falling back to journal_mode=DELETE (slower rollback-journal "
|
|
"mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See "
|
|
"https://www.sqlite.org/wal.html for details. This message "
|
|
"fires once per process per database.",
|
|
db_label,
|
|
exc,
|
|
)
|
|
|
|
|
|
def _log_configured_delete_overridden_once(db_label: str) -> None:
|
|
"""Log a single ERROR per (process, db_label) when the operator configured
|
|
``journal_mode=delete`` but the on-disk DB is already WAL, so the configured
|
|
mode is not in effect.
|
|
|
|
Counterpart to :func:`_log_wal_fallback_once` for the opposite direction:
|
|
there WAL was refused by the filesystem and we silently fell back to DELETE;
|
|
here the operator asked for DELETE but an inherited on-disk WAL header means
|
|
we keep WAL (the never-live-downgrade rule prevents a live downgrade, which
|
|
causes mixed-mode corruption). The signal matters because otherwise the
|
|
operator has no indication that ``database.journal_mode: delete`` had no
|
|
effect and the DB still requires a one-time offline ``PRAGMA
|
|
journal_mode=DELETE`` (with no open connections) to apply.
|
|
|
|
Fires once per process per database.
|
|
"""
|
|
with _delete_overridden_warned_lock:
|
|
if db_label in _delete_overridden_warned_paths:
|
|
return
|
|
_delete_overridden_warned_paths.add(db_label)
|
|
logger.error(
|
|
"%s: database.journal_mode=delete is configured but the on-disk "
|
|
"database is already WAL; keeping WAL (a live downgrade under open "
|
|
"connections can corrupt the DB). To apply journal_mode=DELETE, stop "
|
|
"all connections to this DB and run a one-time offline "
|
|
"'PRAGMA journal_mode=DELETE' on the file. This message fires once "
|
|
"per process per database.",
|
|
db_label,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config-driven database pragmas
|
|
# ---------------------------------------------------------------------------
|
|
# PRAGMA synchronous accepts either an integer or a symbolic name, and operators
|
|
# write the names. Mapped here rather than passed through so a typo becomes a
|
|
# warning instead of a silently different durability level.
|
|
_SYNCHRONOUS_LEVELS: Dict[str, int] = {
|
|
"OFF": 0,
|
|
"NORMAL": 1,
|
|
"FULL": 2,
|
|
"EXTRA": 3,
|
|
}
|
|
_SYNCHRONOUS_NAMES: Dict[int, str] = {v: k for k, v in _SYNCHRONOUS_LEVELS.items()}
|
|
_SYNCHRONOUS_FULL = 2
|
|
|
|
|
|
def resolve_synchronous_level(raw_value: Any) -> Optional[int]:
|
|
"""Map a configured ``database.synchronous`` value to its PRAGMA integer.
|
|
|
|
Accepts the symbolic names SQLite documents (``OFF``/``NORMAL``/``FULL``/
|
|
``EXTRA``, any case) and the equivalent integers ``0``-``3``. Returns None
|
|
for anything else so the caller can warn and leave the level untouched —
|
|
guessing at a malformed durability setting is worse than ignoring it.
|
|
"""
|
|
if isinstance(raw_value, bool):
|
|
# bool is an int subclass, and YAML turns a bare `on`/`off` into one.
|
|
# "off" as a durability level is a real choice; True is meaningless.
|
|
return 0 if raw_value is False else None
|
|
if isinstance(raw_value, int):
|
|
return raw_value if raw_value in _SYNCHRONOUS_NAMES else None
|
|
text = str(raw_value).strip()
|
|
if not text:
|
|
return None
|
|
upper = text.upper()
|
|
if upper in _SYNCHRONOUS_LEVELS:
|
|
return _SYNCHRONOUS_LEVELS[upper]
|
|
try:
|
|
value = int(text)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return value if value in _SYNCHRONOUS_NAMES else None
|
|
|
|
|
|
def _apply_synchronous_pragma(
|
|
conn: sqlite3.Connection,
|
|
raw_value: Any,
|
|
*,
|
|
db_label: str,
|
|
) -> None:
|
|
"""Set ``PRAGMA synchronous`` from config, never below FULL on macOS.
|
|
|
|
Split out of the integer loop in :func:`apply_database_pragmas` because
|
|
this PRAGMA is not interchangeable with the sizing ones around it. Those
|
|
trade memory or disk for speed; this one decides whether a commit is on
|
|
the platter, so an unrecognised value must not fall through to "SQLite
|
|
default" the way a bad ``cache_size`` harmlessly can.
|
|
|
|
The Darwin floor exists because :func:`_enforce_macos_synchronous_full`
|
|
runs during ``apply_wal_with_fallback()`` and this function runs after it,
|
|
so a configured ``NORMAL`` would otherwise silently undo the macOS btree
|
|
protection that fix put there deliberately. Raising the level on macOS is
|
|
allowed; lowering it is refused out loud.
|
|
"""
|
|
level = resolve_synchronous_level(raw_value)
|
|
if level is None:
|
|
logger.warning(
|
|
"%s: ignoring unrecognized database.synchronous=%r "
|
|
"(expected OFF, NORMAL, FULL, EXTRA, or 0-3)",
|
|
db_label,
|
|
raw_value,
|
|
)
|
|
return
|
|
if sys.platform == "darwin" and level < _SYNCHRONOUS_FULL:
|
|
logger.warning(
|
|
"%s: refusing database.synchronous=%s on macOS; keeping FULL. "
|
|
"Darwin's fsync() does not guarantee write ordering, so a lower "
|
|
"level readmits the half-written btree pages FULL exists to "
|
|
"prevent.",
|
|
db_label,
|
|
_SYNCHRONOUS_NAMES[level],
|
|
)
|
|
return
|
|
try:
|
|
conn.execute(f"PRAGMA synchronous={level}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
|
|
def apply_database_pragmas(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
db_label: str = "state.db",
|
|
) -> None:
|
|
"""Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.
|
|
|
|
Reads the ``database:`` section and applies configurable PRAGMAs when set
|
|
to integer values. The journal mode itself is NOT handled here —
|
|
``database.journal_mode`` is owned by :func:`resolve_journal_mode` inside
|
|
:func:`apply_wal_with_fallback`, which layers the operator setting under
|
|
all the safety guards (never live-downgrading an on-disk WAL DB,
|
|
filesystem fallback, WAL-reset-bug gating).
|
|
|
|
Supported keys under ``database:`` in config.yaml:
|
|
|
|
* ``cache_size`` — negative value = KiB, positive = pages
|
|
(e.g. ``-262144`` = 256 MB page cache)
|
|
* ``mmap_size`` — max bytes for memory-mapped I/O (0 = disabled)
|
|
* ``temp_store`` — 0=DEFAULT(file), 1=FILE, 2=MEMORY, 3=ALWAYS
|
|
* ``wal_autocheckpoint`` — WAL auto-checkpoint threshold in pages
|
|
* ``journal_size_limit`` — max journal/WAL size in bytes
|
|
* ``synchronous`` — durability level: ``OFF``/``NORMAL``/``FULL``/``EXTRA``
|
|
or ``0``-``3``. Unset leaves SQLite's own default, which is a
|
|
*compile-time* constant (``SQLITE_DEFAULT_WAL_SYNCHRONOUS``) and so
|
|
differs between the bundled, distro and Homebrew builds an operator
|
|
might be running. Setting it explicitly is the only way to know.
|
|
|
|
Best-effort: config load or pragma failures are ignored so DB init
|
|
never breaks on a malformed ``database:`` section.
|
|
"""
|
|
try:
|
|
# Local import avoids a circular import with hermes_cli.config.
|
|
from hermes_cli.config import cfg_get, load_config_readonly
|
|
|
|
cfg = load_config_readonly()
|
|
except Exception:
|
|
return
|
|
|
|
# Performance PRAGMAs (applied to ALL connection types: writer, read_only,
|
|
# and WAL per-thread readers).
|
|
for pragma_name in (
|
|
"cache_size",
|
|
"mmap_size",
|
|
"temp_store",
|
|
"wal_autocheckpoint",
|
|
"journal_size_limit",
|
|
):
|
|
raw_value = cfg_get(cfg, "database", pragma_name, default=None)
|
|
if raw_value is None:
|
|
continue
|
|
try:
|
|
value = int(str(raw_value).strip())
|
|
except (TypeError, ValueError):
|
|
logger.warning(
|
|
"%s: ignoring non-integer database.%s=%r",
|
|
db_label,
|
|
pragma_name,
|
|
raw_value,
|
|
)
|
|
continue
|
|
try:
|
|
conn.execute(f"PRAGMA {pragma_name}={value}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# Last, so it wins over nothing and loses to nothing: the sizing pragmas
|
|
# above cannot change durability, and the macOS enforcement ran earlier
|
|
# during WAL activation (see _apply_synchronous_pragma for why that
|
|
# ordering needs an explicit floor rather than an explicit override).
|
|
raw_synchronous = cfg_get(cfg, "database", "synchronous", default=None)
|
|
if raw_synchronous is not None:
|
|
_apply_synchronous_pragma(conn, raw_synchronous, db_label=db_label)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
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):
|
|
return "replaced"
|
|
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"
|
|
# 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"
|
|
|
|
|
|
def _claim_repair_attempt(db_path: Path) -> bool:
|
|
"""Claim the one-shot repair attempt for *db_path* in this process.
|
|
|
|
Returns True for the first caller, False afterwards. Keeps a malformed
|
|
DB from triggering an unbounded repair/reopen loop and stops concurrent
|
|
callers from racing surgery on the same file.
|
|
"""
|
|
key = str(db_path)
|
|
with _repair_attempt_lock:
|
|
if key in _repair_attempted_paths:
|
|
return False
|
|
_repair_attempted_paths.add(key)
|
|
return True
|
|
|
|
|
|
# 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
|
|
_REPAIR_LOCK_POLL_SECONDS = 0.1
|
|
# Copying a multi-GB database through SQLite's online-backup API is a data
|
|
# transfer, not an inter-process locking operation. Keep the repair-lock
|
|
# bound separate and give a conservative 10 MiB/s budget to each snapshot or
|
|
# promotion, with the historical two-minute floor for ordinary state.db files.
|
|
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND = 10 * 1024 * 1024
|
|
_IS_WINDOWS = sys.platform == "win32"
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _cross_process_repair_lock(db_path: Path):
|
|
"""Serialize state.db schema surgery across processes.
|
|
|
|
Yields True when this process holds the repair lock for *db_path*, False
|
|
when the bounded acquire timed out. Unlike the kanban init lock — whose
|
|
critical section is idempotent, so proceeding without the lock is merely
|
|
redundant work — proceeding here would be exactly the unsafe interleaving
|
|
we are trying to prevent, so a caller that gets False must NOT do surgery.
|
|
|
|
``flock`` is the right primitive for this: the kernel drops the lock when
|
|
the holding process dies, so a crashed repairer cannot leave a stale lock
|
|
that wedges every future repair (a pidfile would). The acquire is still
|
|
bounded because a *live* repairer can legitimately sit in ``VACUUM`` for
|
|
minutes on a large DB, and an unbounded wait would hang the caller's open
|
|
with no traceback (the failure shape of #36644).
|
|
"""
|
|
lock_path = db_path.with_name(db_path.name + ".repair.lock")
|
|
try:
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = lock_path.open("a+b")
|
|
except OSError as exc:
|
|
# Read-only dir, exhausted fds, exotic filesystem: fall back to the
|
|
# in-process behaviour that shipped before this lock existed rather
|
|
# than refusing to repair a DB we could otherwise heal.
|
|
logger.warning(
|
|
"Could not open state.db repair lock %s (%s) — proceeding with "
|
|
"in-process serialisation only.", lock_path, exc,
|
|
)
|
|
yield True
|
|
return
|
|
|
|
acquired = False
|
|
try:
|
|
deadline = time.monotonic() + _REPAIR_LOCK_TIMEOUT_SECONDS
|
|
while True:
|
|
try:
|
|
if _IS_WINDOWS:
|
|
import msvcrt
|
|
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
acquired = True
|
|
break
|
|
except (BlockingIOError, OSError):
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
time.sleep(_REPAIR_LOCK_POLL_SECONDS)
|
|
if not acquired:
|
|
logger.warning(
|
|
"state.db repair lock %s held by another process for more "
|
|
"than %.0fs — skipping schema surgery in this process to "
|
|
"avoid racing the repairer.",
|
|
lock_path, _REPAIR_LOCK_TIMEOUT_SECONDS,
|
|
)
|
|
yield acquired
|
|
finally:
|
|
try:
|
|
if acquired:
|
|
if _IS_WINDOWS:
|
|
import msvcrt
|
|
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
except OSError: # pragma: no cover - best effort release
|
|
pass
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def _bump_schema_cookie(conn: sqlite3.Connection) -> None:
|
|
"""Increment the schema cookie after direct ``sqlite_master`` surgery.
|
|
|
|
Ordinary DDL bumps this counter for free, and every other connection
|
|
compares it before running a prepared statement — that is how they learn
|
|
to discard a cached schema. Editing ``sqlite_master`` under
|
|
``PRAGMA writable_schema=ON`` does NOT bump it, so live connections in
|
|
other processes keep compiling statements against the schema we just
|
|
deleted objects from — e.g. writing ``messages`` rows through triggers
|
|
into ``messages_fts*`` shadow tables that no longer exist. SQLite's
|
|
writable_schema documentation calls out incrementing ``schema_version``
|
|
as the required companion to such an edit.
|
|
|
|
Best-effort and never raises: a failed bump leaves exactly the
|
|
pre-existing behaviour, and the repair itself is still worth completing.
|
|
"""
|
|
try:
|
|
current = conn.execute("PRAGMA schema_version").fetchone()[0]
|
|
# Wraps within the 32-bit signed range SQLite stores this in; the
|
|
# comparison other connections make is equality, not ordering.
|
|
conn.execute(f"PRAGMA schema_version={(int(current) + 1) & 0x7FFFFFFF}")
|
|
except (sqlite3.DatabaseError, TypeError, IndexError) as exc:
|
|
logger.warning("Could not bump state.db schema cookie: %s", exc)
|
|
|
|
|
|
# ── 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.
|
|
|
|
_MAX_PERSISTENT_REPAIR_ATTEMPTS = 3
|
|
_MAX_MALFORMED_BACKUPS = 3
|
|
|
|
# Sidecars copied alongside a damaged DB and pruned with it. ``-journal`` is
|
|
# included because rollback-journal (DELETE) mode — Hermes's fallback on
|
|
# NFS/SMB/FUSE/ZFS and on WAL-reset-vulnerable SQLite builds — leaves a hot
|
|
# journal on disk whenever a transaction was open, and that file is what
|
|
# interprets the damaged bytes. Omitting it from the forensic copy means the
|
|
# backup cannot be rolled back to a consistent state by hand.
|
|
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
|
|
|
|
# Head/tail bytes sampled by ``_db_fingerprint``. Enough to change whenever
|
|
# the DB is genuinely repaired, truncated or restored (SQLite rewrites the
|
|
# header on any real recovery), while staying O(1) on a multi-GB file.
|
|
_FINGERPRINT_SAMPLE_BYTES = 65536
|
|
|
|
# Byte ranges inside SQLite's 100-byte database header that move on ordinary
|
|
# commits rather than on repair, and are therefore masked out of the content
|
|
# sample. In rollback-journal (DELETE) mode a commit writes the main file
|
|
# directly, bumping the file change counter (24-27) and version-valid-for
|
|
# (92-95); a malformed-SCHEMA DB still accepts those writes, so without the
|
|
# mask any live session write re-keys the ledger and the repair budget resets
|
|
# to 1 forever — the exact unbounded loop this ledger exists to stop. (WAL mode
|
|
# routes commits to the -wal sidecar, so the main file's header only moves on
|
|
# checkpoint; masking is harmless there and correct for both.) Everything that
|
|
# matters for repair identity — the page-1 sqlite_master b-tree — sits after
|
|
# byte 100 and stays in the sample.
|
|
_FINGERPRINT_VOLATILE_HEADER_RANGES = ((24, 28), (92, 96))
|
|
|
|
|
|
def _mask_volatile_header(head: bytes) -> bytes:
|
|
"""Zero the commit-counter fields so ordinary writes don't re-key the ledger."""
|
|
if len(head) < 96:
|
|
return head
|
|
buf = bytearray(head)
|
|
for start, end in _FINGERPRINT_VOLATILE_HEADER_RANGES:
|
|
buf[start:end] = b"\x00" * (end - start)
|
|
return bytes(buf)
|
|
|
|
# Free-space headroom for the pre-repair forensic backup. The backup is a
|
|
# full raw copy of the damaged DB (plus its -wal/-shm sidecars), so a repair
|
|
# loop on a large state.db is a disk amplifier: the reporting incident wrote
|
|
# ~98MB every ~10s until the volume was nearly full, which would have taken
|
|
# down every agent on the host.
|
|
#
|
|
# Proportional, not a flat floor: an absolute multi-GB reserve would refuse
|
|
# backups that fit comfortably on small container/VM volumes, and because a
|
|
# refused backup is a HARD STOP (#69603) that would silently convert "repair
|
|
# loops" into "repair never runs" for those deployments. Require the copy
|
|
# itself plus a small slice of the volume, clamped to a modest floor.
|
|
_REPAIR_BACKUP_MIN_FREE_BYTES = 256 * 1024 * 1024 # 256 MiB absolute floor
|
|
_REPAIR_BACKUP_FREE_FRACTION = 0.02 # plus 2% of the volume
|
|
|
|
|
|
def _repair_backup_headroom_bytes(total_bytes: int) -> int:
|
|
"""Free space required *beyond* the copy itself, for a volume of *total_bytes*."""
|
|
return max(
|
|
_REPAIR_BACKUP_MIN_FREE_BYTES,
|
|
int(total_bytes * _REPAIR_BACKUP_FREE_FRACTION),
|
|
)
|
|
|
|
|
|
def _repair_scratch_space_error(db_path: Path) -> Optional[str]:
|
|
"""Return an error unless snapshot, VACUUM and promotion can fit safely."""
|
|
import shutil
|
|
|
|
try:
|
|
main_bytes = db_path.stat().st_size
|
|
snapshot_bytes = main_bytes
|
|
for suffix in _DB_SIDECAR_SUFFIXES:
|
|
sidecar = db_path.with_name(db_path.name + suffix)
|
|
if sidecar.exists():
|
|
snapshot_bytes += sidecar.stat().st_size
|
|
usage = shutil.disk_usage(db_path.parent)
|
|
headroom = _repair_backup_headroom_bytes(usage.total)
|
|
# Strategy 2 runs VACUUM on the staged database. SQLite documents that
|
|
# VACUUM may need up to twice the database size in additional free
|
|
# space while it builds the replacement and journals the overwrite.
|
|
# Reserve that beyond the snapshot itself; after VACUUM releases its
|
|
# temporary files, the same reserve also covers transactional
|
|
# promotion into the live database.
|
|
required = snapshot_bytes + (2 * snapshot_bytes) + headroom
|
|
if usage.free >= required:
|
|
return None
|
|
return (
|
|
f"only {usage.free / 1e9:.2f}GB free on {db_path.parent}; the "
|
|
f"repair snapshot needs up to {snapshot_bytes / 1e9:.2f}GB, "
|
|
f"VACUUM may need another {(2 * snapshot_bytes) / 1e9:.2f}GB, and "
|
|
f"{headroom / 1e9:.2f}GB must remain as headroom. Free disk space, "
|
|
"then retry."
|
|
)
|
|
except OSError as exc:
|
|
return (
|
|
f"could not determine free space on {db_path.parent} ({exc}); "
|
|
"refusing the repair snapshot rather than risk filling the volume"
|
|
)
|
|
|
|
|
|
def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
|
|
"""Bound one SQLite snapshot by source size, including live sidecars.
|
|
|
|
A WAL can contain committed canonical rows which are not yet present in
|
|
the main database file. Count it (and the rollback journal where
|
|
present), both to describe the work honestly and to avoid applying the
|
|
repair-lock timeout to an otherwise healthy large-database copy.
|
|
"""
|
|
source_bytes = 0
|
|
for suffix in ("", *_DB_SIDECAR_SUFFIXES):
|
|
candidate = (
|
|
source_path
|
|
if not suffix
|
|
else source_path.with_name(source_path.name + suffix)
|
|
)
|
|
try:
|
|
source_bytes += candidate.stat().st_size
|
|
except FileNotFoundError:
|
|
continue
|
|
return max(
|
|
_REPAIR_LOCK_TIMEOUT_SECONDS,
|
|
source_bytes / _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND,
|
|
)
|
|
|
|
|
|
def _repair_failure_consumes_attempt(exc: BaseException) -> bool:
|
|
"""Whether a pre-strategy SQLite failure proves deterministic corruption.
|
|
|
|
Lock contention, timeouts, disk-full, I/O and filesystem failures are
|
|
environmental aborts: retrying later may succeed and must not exhaust the
|
|
repair ledger. Only SQLite's corruption/image result codes prove the
|
|
deterministic damage the bounded ledger exists to stop from retrying
|
|
forever, even when SQLite cannot stage a snapshot far enough to run a
|
|
named strategy.
|
|
"""
|
|
if not isinstance(exc, sqlite3.DatabaseError):
|
|
return False
|
|
error_code = getattr(exc, "sqlite_errorcode", None)
|
|
if isinstance(error_code, int):
|
|
# Extended result codes retain the primary code in the low byte.
|
|
primary_code = error_code & 0xFF
|
|
return primary_code in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB)
|
|
|
|
# sqlite3 versions before exception result-code attributes need a narrow,
|
|
# conservative compatibility path. Do not turn generic DatabaseError
|
|
# messages such as "disk is full" or "readonly" into permanent failures.
|
|
message = str(exc).lower()
|
|
return (
|
|
"file is not a database" in message
|
|
or "database disk image is malformed" in message
|
|
)
|
|
|
|
|
|
def _repair_ledger_path(db_path: Path) -> Path:
|
|
return db_path.with_name(db_path.name + ".repair-attempts.json")
|
|
|
|
|
|
def _db_fingerprint(db_path: Path) -> "Optional[str]":
|
|
"""Cheap identity for a damaged DB file: size + a bounded content sample.
|
|
|
|
Deliberately EXCLUDES mtime. The original ledger keyed on
|
|
``size:mtime_ns`` on the assumption that "nothing can successfully write
|
|
to a damaged file", but that does not hold for the malformed-schema
|
|
class: the DB still opens and accepts writes (only ``sqlite_master`` is
|
|
unreadable), so live writers, WAL checkpoints and the in-place repair
|
|
strategies themselves all move mtime between passes. Every pass then
|
|
looked like a NEW file — the attempt counter reset to 1 forever, never
|
|
reaching ``_MAX_PERSISTENT_REPAIR_ATTEMPTS``, and the ``_backup_db_file``
|
|
dedupe (which compares mtime too) never matched, so each pass wrote
|
|
another full-size forensic copy. Observed: a repair every ~10s, a fresh
|
|
98MB copy each time, 2.3GB in 20 minutes, disk heading to zero.
|
|
|
|
Hashing a multi-GB corrupt file on every open is the repeated cost this
|
|
ledger exists to avoid, so sample instead of digesting the whole file:
|
|
size plus the head/tail slices that any real repair, truncation or
|
|
restore necessarily changes. Stable across passes that merely touch
|
|
mtime; still resets the attempt count after genuine recovery.
|
|
|
|
The content read runs under ``offline_file_access`` because it takes a raw
|
|
descriptor, and ``close()`` on ANY descriptor cancels every POSIX advisory
|
|
lock this process holds on the file — including a peer connection's
|
|
RESERVED lock (see ``hermes_cli.sqlite_safe_read`` rule 1). This function
|
|
is reached from ``repair_state_db_schema``'s exhaustion probe BEFORE
|
|
``_backup_db_file``'s ``has_live_connection`` guard, and the repair path is
|
|
entered by one SessionDB while the gateway holds others, so a live peer is
|
|
the expected case rather than a theoretical one.
|
|
|
|
Returns ``None`` when a live connection makes the read unsafe. Callers MUST
|
|
NOT substitute a differently-shaped key (an earlier revision fell back to
|
|
``size:mtime_ns``): the ledger compares keys for equality, so alternating
|
|
between a content key and an mtime key across passes never matches, the
|
|
counter resets to 1 every time and the unbounded repair loop this ledger
|
|
exists to stop comes straight back. ``None`` means "identity unavailable",
|
|
and the ledger helpers below keep using the key already on record.
|
|
"""
|
|
try:
|
|
st = db_path.stat()
|
|
try:
|
|
from hermes_cli.sqlite_safe_read import (
|
|
LiveConnectionError,
|
|
offline_file_access,
|
|
)
|
|
except ImportError:
|
|
# Scaffold/embed installs ship hermes_state without hermes_cli. No
|
|
# tracked connections exist there, so the raw read is safe.
|
|
@contextmanager
|
|
def offline_file_access(_path, **_kw):
|
|
yield
|
|
|
|
class LiveConnectionError(Exception):
|
|
pass
|
|
|
|
try:
|
|
with offline_file_access(db_path, what="fingerprint"):
|
|
with open(db_path, "rb") as fh:
|
|
head = fh.read(_FINGERPRINT_SAMPLE_BYTES)
|
|
if st.st_size > _FINGERPRINT_SAMPLE_BYTES:
|
|
fh.seek(max(0, st.st_size - _FINGERPRINT_SAMPLE_BYTES))
|
|
tail = fh.read(_FINGERPRINT_SAMPLE_BYTES)
|
|
else:
|
|
tail = b""
|
|
except LiveConnectionError:
|
|
return None
|
|
digest = hashlib.sha256(_mask_volatile_header(head) + tail).hexdigest()[:32]
|
|
return f"{st.st_size}:{digest}"
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _backup_content_identity(db_path: Path) -> "Optional[str]":
|
|
"""Recovery-image identity for forensic-backup dedupe: whole-file + sidecars.
|
|
|
|
This is a DIFFERENT equivalence relation from :func:`_db_fingerprint`, and
|
|
the two MUST NOT be conflated. ``_db_fingerprint`` answers "same repair
|
|
epoch?" — it masks SQLite's commit counters and samples only the head/tail
|
|
so an ordinary write does not mint a fresh repair budget. That is exactly
|
|
the wrong predicate for "may I reuse an existing forensic copy?": a live
|
|
writer can commit new transcript/session rows into an *interior* page while
|
|
preserving file size and leaving the first/last 64 KiB untouched, so two
|
|
materially different recovery images share one ``_db_fingerprint``. Reusing
|
|
a backup on that basis hands the operator a snapshot that predates real
|
|
user data (and #87409 shows a failed in-place repair can still VACUUM
|
|
canonical tables away), so the forensic copy must claim byte identity, not
|
|
epoch identity.
|
|
|
|
So this digests the ENTIRE main file plus every present sidecar
|
|
(``-wal``/``-shm``/``-journal``) — the WAL can hold committed frames not yet
|
|
checkpointed, so it is part of the recovery image. The cost is an O(n) read;
|
|
on a miss the caller is about to do an O(n) *write* (the full raw copy), so
|
|
the read is the cheaper half and never the dominant cost. Runs under
|
|
``offline_file_access`` for the same POSIX-advisory-lock reason as
|
|
``_db_fingerprint``; returns ``None`` when a live connection makes the read
|
|
unsafe (caller then declines to dedupe and takes a fresh backup — the safe
|
|
side, never a false reuse).
|
|
"""
|
|
try:
|
|
from hermes_cli.sqlite_safe_read import (
|
|
LiveConnectionError,
|
|
offline_file_access,
|
|
)
|
|
except ImportError:
|
|
@contextmanager
|
|
def offline_file_access(_path, **_kw):
|
|
yield
|
|
|
|
class LiveConnectionError(Exception):
|
|
pass
|
|
|
|
def _hash_whole(path: Path, hasher: "Any") -> None:
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
hasher.update(chunk)
|
|
|
|
try:
|
|
hasher = hashlib.sha256()
|
|
with offline_file_access(db_path, what="backup-identity"):
|
|
# Length-delimit every member (main file included) so the
|
|
# concatenation is prefix-free — otherwise a main-file tail could
|
|
# coincide with a main+sidecar split and dedupe two different
|
|
# recovery images together.
|
|
hasher.update(f"\0main:{db_path.stat().st_size}\0".encode())
|
|
_hash_whole(db_path, hasher)
|
|
for suffix in _DB_SIDECAR_SUFFIXES:
|
|
sidecar = db_path.with_name(db_path.name + suffix)
|
|
if sidecar.exists():
|
|
hasher.update(f"\0{suffix}:{sidecar.stat().st_size}\0".encode())
|
|
_hash_whole(sidecar, hasher)
|
|
return hasher.hexdigest()
|
|
except LiveConnectionError:
|
|
return None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _read_repair_ledger(db_path: Path) -> "Dict[str, Any]":
|
|
try:
|
|
raw = json.loads(_repair_ledger_path(db_path).read_text(encoding="utf-8"))
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
except (OSError, ValueError):
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _persistent_repair_attempts_exhausted(db_path: Path) -> bool:
|
|
"""Whether *db_path* has already burned its cross-restart repair budget.
|
|
|
|
True only when the ledger records ``_MAX_PERSISTENT_REPAIR_ATTEMPTS``
|
|
failed attempts against the CURRENT file fingerprint. Never raises; a
|
|
missing/corrupt ledger or unstatable DB reads as "not exhausted" (the
|
|
in-process claim and cross-process lock still bound a single run).
|
|
|
|
When the fingerprint is unavailable because a live connection makes the
|
|
content read unsafe, fall back to the SIZE the ledger recorded rather than
|
|
reading as "not exhausted". Otherwise a peer connection is enough to hide
|
|
an exhausted budget on every pass, which is the unbounded loop again.
|
|
"""
|
|
ledger = _read_repair_ledger(db_path)
|
|
recorded = ledger.get("fingerprint")
|
|
fp = _db_fingerprint(db_path)
|
|
if fp is None:
|
|
# Size is the one component both key shapes share and that a raw read
|
|
# is not needed for; an unchanged size means the damaged file is very
|
|
# likely the same one the budget was burned on.
|
|
try:
|
|
size_prefix = f"{db_path.stat().st_size}:"
|
|
except OSError:
|
|
return False
|
|
if not isinstance(recorded, str) or not recorded.startswith(size_prefix):
|
|
return False
|
|
elif recorded != fp:
|
|
return False
|
|
return int(ledger.get("failed_attempts", 0)) >= _MAX_PERSISTENT_REPAIR_ATTEMPTS
|
|
|
|
|
|
def _persistent_repair_exhausted_error(db_path: Path) -> str:
|
|
"""The stable operator-facing diagnostic for an exhausted repair budget."""
|
|
return (
|
|
f"automatic repair has already failed "
|
|
f"{_MAX_PERSISTENT_REPAIR_ATTEMPTS} times on this exact file — "
|
|
"the corruption is beyond the schema/FTS repair strategies "
|
|
"(likely b-tree page damage). Manual recovery required: restore "
|
|
f"a backup, or salvage with `sqlite3 {db_path} \".recover\"`. "
|
|
f"Delete {_repair_ledger_path(db_path).name} to force another "
|
|
"automatic attempt."
|
|
)
|
|
|
|
|
|
def _record_repair_outcome(
|
|
db_path: Path, *, repaired: bool, fingerprint: "Optional[str]" = None
|
|
) -> None:
|
|
"""Update the persistent attempt ledger after a repair pass. Never raises.
|
|
|
|
Defaults to the post-attempt fingerprint — the file state the NEXT
|
|
attempt's exhaustion probe will observe.
|
|
|
|
When the fingerprint is unavailable (a live connection makes the content
|
|
read unsafe), keep the key already on record and still increment: dropping
|
|
the pass would let a peer connection reset the budget every time, which is
|
|
the unbounded loop this ledger exists to stop. Never write a differently
|
|
shaped key — the probe compares for equality, so mixing key shapes across
|
|
passes never matches.
|
|
"""
|
|
ledger_path = _repair_ledger_path(db_path)
|
|
try:
|
|
if repaired:
|
|
ledger_path.unlink(missing_ok=True)
|
|
return
|
|
ledger = _read_repair_ledger(db_path)
|
|
recorded = ledger.get("fingerprint")
|
|
fp = fingerprint if fingerprint is not None else _db_fingerprint(db_path)
|
|
if fp is None:
|
|
if not isinstance(recorded, str):
|
|
# No prior key to extend and no way to mint one safely: the
|
|
# in-process claim and cross-process lock still bound this run.
|
|
return
|
|
fp = recorded
|
|
attempts = (
|
|
int(ledger.get("failed_attempts", 0)) + 1 if recorded == fp else 1
|
|
)
|
|
import datetime
|
|
|
|
ledger_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"fingerprint": fp,
|
|
"failed_attempts": attempts,
|
|
"last_attempt": datetime.datetime.now().isoformat(
|
|
timespec="seconds"
|
|
),
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
except Exception as exc: # pragma: no cover - best effort
|
|
logger.warning("Could not update state.db repair ledger: %s", exc)
|
|
|
|
|
|
def _existing_malformed_backups(db_path: Path) -> "List[Path]":
|
|
"""Timestamped forensic backups of *db_path*, newest first."""
|
|
prefix = f"{db_path.name}.malformed-backup-"
|
|
try:
|
|
found = [
|
|
p
|
|
for p in db_path.parent.iterdir()
|
|
if p.name.startswith(prefix)
|
|
and not p.name.endswith(_DB_SIDECAR_SUFFIXES)
|
|
]
|
|
except OSError:
|
|
return []
|
|
return sorted(found, key=lambda p: p.name, reverse=True)
|
|
|
|
|
|
def _prune_malformed_backups(db_path: Path, keep: int = _MAX_MALFORMED_BACKUPS) -> None:
|
|
"""Delete all but the *keep* newest forensic backups (and sidecars)."""
|
|
for stale in _existing_malformed_backups(db_path)[keep:]:
|
|
for victim in (
|
|
stale,
|
|
*(stale.with_name(stale.name + suffix) for suffix in _DB_SIDECAR_SUFFIXES),
|
|
):
|
|
try:
|
|
victim.unlink(missing_ok=True)
|
|
except OSError as exc: # pragma: no cover - best effort
|
|
logger.warning("Could not prune stale DB backup %s: %s", victim, exc)
|
|
|
|
|
|
def _backup_db_file(db_path: Path) -> "Tuple[Optional[Path], Optional[str]]":
|
|
"""Copy a (possibly malformed) DB file to a timestamped backup beside it.
|
|
Raw file copy on purpose: the DB won't open cleanly, so we preserve the
|
|
bytes exactly for forensics / manual restore. WAL, SHM and rollback-journal
|
|
sidecars are copied too when present. Returns ``(backup_path, None)`` on success or
|
|
``(None, reason)`` on failure — callers on the repair path treat a
|
|
refused backup as a HARD STOP (see #69603). Repair strategies run on a
|
|
scratch snapshot, but the forensic bundle remains the recovery path when
|
|
corruption defeats them.
|
|
|
|
Refuses when a connection to this database is still live in the process:
|
|
reading the file would ``close()`` a descriptor for it and cancel that
|
|
connection's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``).
|
|
The repair path can be entered by one SessionDB while the gateway holds
|
|
others, so this is a real possibility rather than a theoretical one.
|
|
"""
|
|
import datetime
|
|
import shutil
|
|
|
|
try:
|
|
from hermes_cli.sqlite_safe_read import has_live_connection
|
|
except ImportError:
|
|
has_live_connection = None # type: ignore[assignment]
|
|
|
|
if has_live_connection is not None and has_live_connection(db_path):
|
|
reason = (
|
|
f"a connection to {db_path} is still open in this process; "
|
|
"raw-copying it would cancel that connection's POSIX advisory "
|
|
"locks. Close all SessionDB handles first."
|
|
)
|
|
logger.error("Refusing to raw-copy %s for backup: %s", db_path, reason)
|
|
return None, reason
|
|
|
|
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
|
|
# Same-second collision (two distinct damaged states within one second)
|
|
# must not silently overwrite the earlier forensic copy.
|
|
seq = 1
|
|
while backup_path.exists():
|
|
backup_path = db_path.with_name(
|
|
f"{db_path.name}.malformed-backup-{stamp}_{seq}"
|
|
)
|
|
seq += 1
|
|
try:
|
|
# Sweep staging debris from an earlier interrupted pass (kill mid-copy)
|
|
# BEFORE the dedupe below. A leftover staging file is a byte-identical
|
|
# copy of the damaged DB, so its fingerprint MATCHES and the dedupe
|
|
# would otherwise hand it back as a legitimate forensic backup.
|
|
# Matches sidecar staging names (``.backup-staging-<stamp>-wal``) too.
|
|
# The second pattern is the pre-merge ``.incomplete`` spelling, swept so
|
|
# a host that ran that build does not keep prefix-matching debris that
|
|
# sorts NEWEST and survives prune forever.
|
|
for pattern in (
|
|
f"{db_path.name}.backup-staging-*",
|
|
f"{db_path.name}.malformed-backup-*.incomplete*",
|
|
):
|
|
for old in db_path.parent.glob(pattern):
|
|
try:
|
|
old.unlink(missing_ok=True)
|
|
except OSError: # pragma: no cover - best effort
|
|
pass
|
|
# Dedupe (#86747): a repair loop used to copy the SAME damaged bytes
|
|
# on every restart — ~900MB a pass, 89GB over 11 days in the
|
|
# reporting install. If the newest existing backup is byte-identical to
|
|
# the current recovery image, reuse it.
|
|
#
|
|
# Matching on mtime made this dedupe miss exactly when it mattered
|
|
# most: the malformed-SCHEMA class still accepts writes, so live
|
|
# writers and the in-place repair strategies move mtime between
|
|
# passes and every pass wrote another full-size copy (2.3GB in 20
|
|
# minutes).
|
|
#
|
|
# Use ``_backup_content_identity`` (whole file + sidecars), NOT the
|
|
# repair-epoch ``_db_fingerprint``. They are different equivalence
|
|
# relations: the fingerprint masks commit counters and samples only
|
|
# head/tail so an ordinary interior-page write does not re-key the
|
|
# repair budget — but that same write DOES change the recovery image,
|
|
# and deduping on the fingerprint would hand back a stale backup that
|
|
# predates the write. A forensic copy must prove byte identity, so it
|
|
# pays the O(n) read (cheaper than the O(n) write it avoids on a hit).
|
|
try:
|
|
# Only hash the source when there is actually a candidate to dedupe
|
|
# against — on the common first-corruption pass there is no prior
|
|
# backup, and hashing the (possibly multi-GB) source then would be
|
|
# pure waste right before the copy reads it again anyway.
|
|
existing_backups = _existing_malformed_backups(db_path)[:1]
|
|
if existing_backups:
|
|
src_id = _backup_content_identity(db_path)
|
|
for existing in existing_backups:
|
|
if src_id is not None and _backup_content_identity(existing) == src_id:
|
|
logger.info(
|
|
"Reusing existing forensic backup %s (identical to the "
|
|
"damaged DB).", existing,
|
|
)
|
|
return existing, None
|
|
except OSError:
|
|
pass
|
|
# Disk guard: this is a full raw copy of a possibly multi-GB DB plus
|
|
# its sidecars. On a host whose volume is already nearly full — which
|
|
# a preceding repair loop may itself have caused — taking it can
|
|
# finish off the disk and take down every process on the machine.
|
|
# Refuse while there is still room to refuse in.
|
|
try:
|
|
need = db_path.stat().st_size
|
|
for suffix in _DB_SIDECAR_SUFFIXES:
|
|
sidecar = db_path.with_name(db_path.name + suffix)
|
|
if sidecar.exists():
|
|
need += sidecar.stat().st_size
|
|
usage = shutil.disk_usage(db_path.parent)
|
|
headroom = _repair_backup_headroom_bytes(usage.total)
|
|
if usage.free - need < headroom:
|
|
reason = (
|
|
f"only {usage.free / 1e9:.2f}GB free on {db_path.parent}; "
|
|
f"copying the damaged DB needs {need / 1e9:.2f}GB and must "
|
|
f"leave {headroom / 1e9:.2f}GB headroom. Free disk space, "
|
|
f"then retry (or recover manually with `sqlite3 {db_path} "
|
|
'".recover"`).'
|
|
)
|
|
logger.error("Refusing forensic backup of %s: %s", db_path, reason)
|
|
return None, reason
|
|
except OSError as exc:
|
|
# Fail CLOSED. This guard exists for the nearly-full volume, which
|
|
# is exactly where stat()/disk_usage() is most likely to fail — and
|
|
# proceeding would take the multi-GB copy that finishes off the
|
|
# disk. A refused backup is a HARD STOP (#69603), so repair simply
|
|
# does not run until a human frees space, which is the safe side.
|
|
reason = (
|
|
f"could not determine free space on {db_path.parent} ({exc}); "
|
|
"refusing the forensic copy rather than risk filling the "
|
|
f"volume. Free disk space, then retry (or recover manually "
|
|
f'with `sqlite3 {db_path} ".recover"`).'
|
|
)
|
|
logger.error("Refusing forensic backup of %s: %s", db_path, reason)
|
|
return None, reason
|
|
# Copy to a staging name OUTSIDE the ``.malformed-backup-`` prefix, then
|
|
# rename into place only once every copy has succeeded. The prefix
|
|
# matters: ``_existing_malformed_backups`` matches on
|
|
# ``startswith(f"{db}.malformed-backup-")`` and excludes only ``-wal``/
|
|
# ``-shm`` suffixes, so a staging name derived from the backup name (e.g.
|
|
# ``…malformed-backup-<stamp>.incomplete``) still counts as a backup —
|
|
# it sorts NEWEST (``.incomplete`` > the bare stamp), so prune's
|
|
# keep-3-newest slice retained partials and deleted intact copies, and
|
|
# the dedupe could hand a partial back as the official ``backup_path``,
|
|
# passing the #69603 hard-stop gate with no real forensic copy on disk.
|
|
staging = db_path.with_name(f"{db_path.name}.backup-staging-{stamp}")
|
|
# (staging_src, final_dst) pairs. ORDER MATTERS for publication: the
|
|
# main-DB backup name is the bundle's commit marker —
|
|
# ``_existing_malformed_backups`` matches ``{db}.malformed-backup-*``
|
|
# and excludes only the ``-wal``/``-shm``/``-journal`` suffixes, so the
|
|
# main file appearing is what makes the bundle "count". Sidecars are
|
|
# therefore staged/published FIRST and the main DB LAST, so a failure
|
|
# partway through never leaves a countable main backup standing over a
|
|
# missing sidecar (an incomplete recovery image that would pass the
|
|
# #69603 hard stop and dedupe as legitimate on the next pass).
|
|
staged_sidecars: "List[Tuple[Path, Path, Path]]" = []
|
|
for suffix in _DB_SIDECAR_SUFFIXES:
|
|
sidecar = db_path.with_name(db_path.name + suffix)
|
|
if sidecar.exists():
|
|
side_staging = staging.with_name(staging.name + suffix)
|
|
side_dst = backup_path.with_name(backup_path.name + suffix)
|
|
staged_sidecars.append((sidecar, side_staging, side_dst))
|
|
main_pair = (staging, backup_path)
|
|
published: "List[Path]" = []
|
|
all_staging_srcs = [staging] + [s for _src, s, _d in staged_sidecars]
|
|
try:
|
|
shutil.copy2(db_path, staging)
|
|
for sidecar, side_staging, _side_dst in staged_sidecars:
|
|
shutil.copy2(sidecar, side_staging)
|
|
# Publish sidecars first, main DB LAST (the commit marker), so a
|
|
# mid-publish failure never leaves a countable-but-incomplete bundle.
|
|
publish_order = [
|
|
(s, d) for _src, s, d in staged_sidecars
|
|
] + [main_pair]
|
|
for src, dst in publish_order:
|
|
os.replace(src, dst)
|
|
published.append(dst)
|
|
except Exception:
|
|
# Roll back BOTH unpublished staging files AND anything already
|
|
# promoted — the old code unlinked only staging srcs, so a failure
|
|
# after the main os.replace left the official backup_path on disk.
|
|
for src in all_staging_srcs:
|
|
try:
|
|
src.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
for dst in published:
|
|
try:
|
|
dst.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
staging.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
# Retention cap (#86747): keep only the newest few forensic copies.
|
|
_prune_malformed_backups(db_path)
|
|
return backup_path, None
|
|
except Exception as exc: # pragma: no cover - best effort
|
|
logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
|
|
return None, f"backup copy failed: {exc}"
|
|
|
|
|
|
def preflight_db_writability(
|
|
db_path: Path,
|
|
*,
|
|
db_label: str = "state.db",
|
|
) -> None:
|
|
"""Refuse-or-repair read-only DB files BEFORE the first connection opens.
|
|
|
|
Port of Kilo-Org/kilocode#12508's startup preflight. A stray read-only
|
|
``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored backup, copied
|
|
dotfiles) previously surfaced as an opaque
|
|
``sqlite3.OperationalError: attempt to write a readonly database`` raised
|
|
from deep inside ``_init_schema`` — naming no file and no fix — and the
|
|
obvious wrong "fix" (deleting the ``-wal``) silently loses committed
|
|
transactions. This preflight:
|
|
|
|
- **Repairs** permissions with ``chmod u+rw`` when the file lives inside
|
|
the Hermes home tree (``get_hermes_home()``) — the safe repair scope:
|
|
Hermes owns those files, and the OS makes ``chmod`` fail on files the
|
|
user doesn't own, which bounds the repair exactly.
|
|
- **Fails fast with an actionable error** naming the exact file and the
|
|
exact ``chmod`` command for anything else (root-owned files, read-only
|
|
mounts, custom paths outside the home tree).
|
|
- Never deletes or truncates a WAL sidecar — once writable, the normal
|
|
open path checkpoints its committed frames into the DB as intended.
|
|
|
|
``:memory:`` and ``file:`` URI paths are skipped (no plain on-disk files
|
|
to check). Shared by :class:`SessionDB` and ``hermes_cli.kanban_db``.
|
|
"""
|
|
raw = str(db_path)
|
|
if raw == ":memory:" or raw.startswith("file:"):
|
|
return
|
|
|
|
try:
|
|
home: Optional[Path] = Path(get_hermes_home()).resolve()
|
|
except Exception: # pragma: no cover - defensive
|
|
home = None
|
|
|
|
def _in_repair_scope(p: Path) -> bool:
|
|
if home is None:
|
|
return False
|
|
try:
|
|
return p.resolve().is_relative_to(home)
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
def _ensure_writable(p: Path, *, is_dir: bool = False) -> None:
|
|
import stat as _stat
|
|
|
|
if os.access(p, os.R_OK | os.W_OK):
|
|
return
|
|
if _in_repair_scope(p):
|
|
try:
|
|
add = _stat.S_IRUSR | _stat.S_IWUSR | (_stat.S_IXUSR if is_dir else 0)
|
|
os.chmod(p, p.stat().st_mode | add)
|
|
except OSError:
|
|
pass
|
|
if os.access(p, os.R_OK | os.W_OK):
|
|
logger.info(
|
|
"%s preflight: repaired read-only %s (chmod u+rw%s)",
|
|
db_label,
|
|
p,
|
|
"x" if is_dir else "",
|
|
)
|
|
return
|
|
kind = "directory" if is_dir else "file"
|
|
wal_note = (
|
|
" Do NOT delete the -wal file — it contains committed data that "
|
|
"will be merged into the database once it is writable."
|
|
if p.name.endswith("-wal")
|
|
else ""
|
|
)
|
|
raise sqlite3.OperationalError(
|
|
f"{db_label} is not writable: {kind} {p} is read-only for this "
|
|
f"user. Hermes needs read-write access to open the database. "
|
|
f"Fix with: chmod u+rw{'x' if is_dir else ''} '{p}'"
|
|
f" (files owned by another user may need sudo/chown).{wal_note}"
|
|
)
|
|
|
|
parent = db_path.parent
|
|
if parent.is_dir():
|
|
# SQLite needs a writable directory in every journal mode (WAL and
|
|
# SHM sidecars in WAL mode; the rollback journal in DELETE mode).
|
|
_ensure_writable(parent, is_dir=True)
|
|
|
|
for suffix in ("", "-wal", "-shm"):
|
|
p = db_path.with_name(db_path.name + suffix) if suffix else db_path
|
|
if p.is_file():
|
|
_ensure_writable(p)
|
|
|
|
|
|
def _connect_repair_durable(
|
|
db_path: Path, *, timeout: float = 5.0
|
|
) -> sqlite3.Connection:
|
|
"""``sqlite3.connect`` for the repair/probe paths, with macOS write barriers.
|
|
|
|
These paths open ``state.db`` directly rather than through ``SessionDB``
|
|
(which routes via :func:`apply_wal_with_fallback`), so they inherited
|
|
SQLite's ``synchronous=NORMAL`` default and no ``checkpoint_fullfsync``.
|
|
On Darwin that is exactly the combination :func:`_enforce_macos_synchronous_full`
|
|
exists to prevent: ``fsync()`` there guarantees neither data-on-platter nor
|
|
write ordering, so a rewrite interrupted by process or OS termination can
|
|
leave half-written b-tree pages behind.
|
|
|
|
That matters more here than anywhere else in the module, because what runs
|
|
through these connections is ``REINDEX``, ``VACUUM`` and ``writable_schema``
|
|
surgery — the operations that rewrite nearly every page of the file. The
|
|
2026-08-19 recurrence tore ``messages`` (root page 5) and
|
|
``idx_messages_session``, reporting the unmistakable signature: repeated
|
|
"2nd reference to page", a rowid out of order, and long runs of leaked
|
|
"never used" pages.
|
|
|
|
Autocommit (``isolation_level=None``) is preserved: callers run DDL and
|
|
``VACUUM``, which are illegal inside an implicit transaction.
|
|
|
|
Applying the barriers is best-effort *by necessity*: SQLite loads the
|
|
schema before it runs any statement, so on a malformed schema even
|
|
``PRAGMA synchronous=FULL`` raises ``DatabaseError`` ("malformed database
|
|
schema (messages_fts) - table messages_fts already exists"). A malformed
|
|
database is precisely this helper's input, so raising there would leave
|
|
repair unable to open the file it exists to fix. Strategies that go on to
|
|
rewrite the whole file call :func:`_reapply_durability_barriers` once the
|
|
schema parses again, which is the point at which the pragmas can stick.
|
|
"""
|
|
conn = sqlite3.connect(str(db_path), timeout=timeout, isolation_level=None)
|
|
_reapply_durability_barriers(conn)
|
|
return conn
|
|
|
|
|
|
def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
|
"""Best-effort (re)application of the macOS write barriers. Never raises.
|
|
|
|
Returns True when the pragmas were accepted. Callers about to rewrite the
|
|
file wholesale (``VACUUM``, ``REINDEX``) should call this after the schema
|
|
becomes parseable, because a connection opened against a malformed schema
|
|
could not take them at open time.
|
|
"""
|
|
try:
|
|
_apply_macos_checkpoint_barrier(conn)
|
|
_enforce_macos_synchronous_full(conn)
|
|
return True
|
|
except sqlite3.DatabaseError:
|
|
# Schema still unparseable — the pragmas cannot be set yet.
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
|
"""Apply state-store durability barriers without changing journal mode.
|
|
|
|
This is the public entry point for secondary users of ``state.db`` that
|
|
must inherit its owner's journal mode while retaining per-connection
|
|
durability settings. Also applies the configured ``database.synchronous``
|
|
level (a per-connection pragma that would otherwise only ride on the
|
|
journal-mode setup path guest connections must not run).
|
|
"""
|
|
ok = _reapply_durability_barriers(conn)
|
|
try:
|
|
# Local import avoids a circular import with hermes_cli.config.
|
|
from hermes_cli.config import cfg_get, load_config_readonly
|
|
|
|
cfg = load_config_readonly()
|
|
raw_synchronous = cfg_get(cfg, "database", "synchronous", default=None)
|
|
if raw_synchronous is not None:
|
|
_apply_synchronous_pragma(
|
|
conn, raw_synchronous, db_label="state.db (guest)"
|
|
)
|
|
except Exception:
|
|
pass
|
|
return ok
|
|
|
|
|
|
@contextmanager
|
|
def _exclusive_repair_db_guard(db_path: Path):
|
|
"""Yield one live connection that excludes writers for repair surgery.
|
|
|
|
``locking_mode=EXCLUSIVE`` retains SQLite's file-level exclusion after the
|
|
short ``BEGIN EXCLUSIVE`` transaction is rolled back. That rollback is
|
|
essential: ``Connection.backup`` may use the guarded connection as a
|
|
*source* while it is transaction-free, and it must be transaction-free
|
|
when it is later the promotion *destination*. The connection itself
|
|
remains open for the entire snapshot -> strategies -> promotion window,
|
|
so another writer cannot commit a change that promotion could overwrite.
|
|
|
|
Existing WAL readers make exclusive acquisition fail rather than being
|
|
disturbed. In DELETE mode an existing reader similarly prevents
|
|
``BEGIN EXCLUSIVE``; a future reader/writer waits behind the guard. A
|
|
repair therefore fails closed whenever this process cannot own that whole
|
|
window.
|
|
"""
|
|
guard: Optional[sqlite3.Connection] = None
|
|
try:
|
|
# The cross-process repair lock already serializes repairers. Do not
|
|
# wait behind an ordinary application connection: a partial repair is
|
|
# less safe than an explicit "stop the gateway and retry" result.
|
|
guard = _connect_repair_durable(db_path, timeout=0.0)
|
|
guard.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
guard.execute("BEGIN EXCLUSIVE")
|
|
guard.execute("ROLLBACK")
|
|
except (sqlite3.Error, OSError) as exc:
|
|
if guard is not None:
|
|
try:
|
|
guard.execute("PRAGMA locking_mode=NORMAL")
|
|
except Exception:
|
|
pass
|
|
guard.close()
|
|
yield None, exc
|
|
return
|
|
|
|
try:
|
|
yield guard, None
|
|
finally:
|
|
try:
|
|
# Let SQLite release the exclusive locks before close; this also
|
|
# avoids a connection-close checkpoint being mistaken for a
|
|
# repair write in callers that immediately reopen state.db.
|
|
guard.execute("PRAGMA locking_mode=NORMAL")
|
|
except Exception:
|
|
pass
|
|
guard.close()
|
|
|
|
|
|
def _copy_database_snapshot(
|
|
source_path: Path,
|
|
destination_path: Path,
|
|
*,
|
|
source_connection: Optional[sqlite3.Connection] = None,
|
|
destination_connection: Optional[sqlite3.Connection] = None,
|
|
) -> None:
|
|
"""Copy one complete SQLite snapshot without replacing either file inode.
|
|
|
|
SQLite's online backup API incorporates committed WAL frames into the
|
|
source snapshot and writes the destination inside one transaction. This
|
|
avoids both the main-file-only staging gap and replacing ``state.db`` from
|
|
under handles that already refer to it. If backup is interrupted, SQLite
|
|
rolls the destination transaction back.
|
|
"""
|
|
# Work out the deadline before opening an owned source connection. A
|
|
# sidecar disappearing while we stat it is an ordinary staging failure,
|
|
# but it must not leak a just-opened SQLite descriptor.
|
|
deadline_seconds = _repair_snapshot_timeout_seconds(source_path)
|
|
deadline = time.monotonic() + deadline_seconds
|
|
source = source_connection or _connect_repair_durable(source_path)
|
|
destination = destination_connection
|
|
own_source = source_connection is None
|
|
own_destination = destination_connection is None
|
|
|
|
def _check_deadline(_status: int, _remaining: int, _total: int) -> None:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(
|
|
"timed out copying SQLite repair snapshot after "
|
|
f"{deadline_seconds:.0f}s"
|
|
)
|
|
|
|
try:
|
|
if destination is None:
|
|
destination = _connect_repair_durable(destination_path)
|
|
elif destination.in_transaction:
|
|
# sqlite3_backup requires a transaction-free destination. The
|
|
# exclusive repair guard deliberately retains file exclusion via
|
|
# locking_mode, not an active transaction, so it satisfies this.
|
|
raise sqlite3.ProgrammingError(
|
|
"SQLite repair backup destination has an active transaction"
|
|
)
|
|
source.backup(
|
|
destination,
|
|
pages=256,
|
|
progress=_check_deadline,
|
|
sleep=_REPAIR_LOCK_POLL_SECONDS,
|
|
)
|
|
finally:
|
|
if own_destination and destination is not None:
|
|
destination.close()
|
|
if own_source:
|
|
source.close()
|
|
|
|
|
|
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
|
|
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
|
|
|
|
Runs the same first-statement (``PRAGMA journal_mode``) that trips the
|
|
malformed-schema parse, then ``PRAGMA integrity_check`` and a canonical
|
|
``sessions`` read, and finally a rolled-back ``messages`` write so that
|
|
FTS5 index corruption — which leaves base-table reads and
|
|
``integrity_check`` passing while every ``INSERT INTO messages`` fails
|
|
through the FTS triggers — is reported as unhealthy rather than slipping
|
|
past as a false "ok" (#50502).
|
|
"""
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
# Best-effort tokenizer load: a DB carrying the messages_fts_cjk
|
|
# index needs the cjk_unicode61 extension before any statement can
|
|
# touch that table — including the trigger-driven write probe below.
|
|
# Without it, this probe sees the DB exactly as a tokenizer-less
|
|
# SessionDB open would (which drops the cjk triggers to keep writes
|
|
# working), so tokenizer absence must never classify as corruption.
|
|
load_fts5_cjk_extension(conn)
|
|
conn.execute("PRAGMA journal_mode").fetchone()
|
|
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
|
problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
|
|
if problems:
|
|
return "; ".join(problems[:3])
|
|
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
|
|
|
|
# FTS5 read probe: run a representative MATCH query against the
|
|
# messages_fts* virtual tables. The FTS *write* probe below catches
|
|
# the corruption class where base tables read fine but writes fail
|
|
# through the triggers (#50502). It does NOT catch partial FTS5
|
|
# index corruption — bad shadow-table segments where reads still
|
|
# parse but MATCH / snippet / rank queries error out with
|
|
# "database disk image is malformed" (a `sqlite3.DatabaseError`,
|
|
# not `OperationalError`). session_search, /resume title resolution,
|
|
# and any feature relying on FTS5 discovery then break silently
|
|
# because the official repair tool's check-only path reports the
|
|
# DB as healthy. #66724.
|
|
# Catch the full sqlite3 exception hierarchy (not just
|
|
# OperationalError) so the malformed-shadow-table class is reported
|
|
# rather than letting it crash the caller.
|
|
for fts_table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
|
|
try:
|
|
# No-op queries against the actual FTS5 APIs the search
|
|
# tools use. The trigram table is included because it backs
|
|
# the title-resolution path; either corruption mode would
|
|
# break session recall without this probe. MATCH '""' is
|
|
# the empty phrase-token probe — FTS5 rejects MATCH ''
|
|
# outright ("fts5: syntax error"), but a quoted empty
|
|
# phrase parses, scans zero rows, and exercises the same
|
|
# shadow-table read path the search tools use.
|
|
conn.execute(
|
|
f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1"
|
|
).fetchone()
|
|
except sqlite3.OperationalError as exc:
|
|
# Use the canonical capability classifier instead of a
|
|
# hand-rolled substring check. On SQLite builds without the
|
|
# fts5 module, the legacy messages_fts table may exist on
|
|
# disk (from a prior build that had FTS5) and MATCH queries
|
|
# against it raise OperationalError("no such module: fts5");
|
|
# the substring check below would misclassify that as
|
|
# corruption and send the DB into the repair path, whose
|
|
# final fallback deletes the messages_fts% schema
|
|
# (hermes_state.py:645-723). The supported degraded-runtime
|
|
# path (SessionDB._is_fts5_unavailable_error + the
|
|
# regression suite in tests/test_hermes_state.py:600-632)
|
|
# treats both "no such module: fts5" and
|
|
# "no such tokenizer: trigram" as the capability error.
|
|
if SessionDB._is_fts5_unavailable_error(exc):
|
|
# Degraded runtime — not the corruption class we probe.
|
|
continue
|
|
msg = str(exc).lower()
|
|
if "no such table" in msg or "no such column" in msg:
|
|
# FTS5 not built yet (brand new file mid-init) — not the
|
|
# corruption class we probe.
|
|
continue
|
|
return f"fts5 read probe failed on {fts_table}: {exc}"
|
|
except sqlite3.DatabaseError as exc:
|
|
# This is the corruption class #66724 actually wants caught:
|
|
# partial shadow-table damage where MATCH / snippet / rank
|
|
# queries raise DatabaseError("database disk image is malformed")
|
|
# while reads of the FTS5 table itself parse fine.
|
|
return f"fts5 read probe failed on {fts_table}: {exc}"
|
|
|
|
# FTS write probe: drive a row through the messages_fts* triggers in a
|
|
# transaction that is always rolled back, so a corrupt FTS index that
|
|
# rejects writes is caught even though reads look healthy. The probe is
|
|
# best-effort — if the messages/sessions tables don't exist yet (brand
|
|
# new file mid-init) the OperationalError is treated as "not yet a
|
|
# populated DB", not corruption.
|
|
probe_session_id = f"_hermes_fts_health_probe_{time.time_ns()}"
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
|
(probe_session_id, "_health_probe", time.time()),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(probe_session_id, "user", "_fts_health_probe", time.time()),
|
|
)
|
|
conn.execute("ROLLBACK")
|
|
except sqlite3.OperationalError as exc:
|
|
# Missing tables / FTS disabled — not the corruption class we probe.
|
|
try:
|
|
conn.execute("ROLLBACK")
|
|
except sqlite3.Error:
|
|
pass
|
|
msg = str(exc).lower()
|
|
if "no such table" in msg or "no such column" in msg:
|
|
return None
|
|
if "no such tokenizer: cjk_unicode61" in msg:
|
|
# This probe process couldn't load the cjk extension while
|
|
# the DB carries the cjk index — capability gap, not
|
|
# corruption. A tokenizer-capable SessionDB serves it fine;
|
|
# a tokenizer-less one self-heals by dropping the triggers.
|
|
return None
|
|
return str(exc)
|
|
return None
|
|
except sqlite3.DatabaseError as exc:
|
|
return str(exc)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _live_writer_holds_db(db_path: Path) -> bool:
|
|
"""True when a connection outside this call still holds ``db_path`` open.
|
|
|
|
Detection works by asking SQLite for the thing a repair actually needs and
|
|
a live writer cannot grant: ``PRAGMA locking_mode=EXCLUSIVE`` followed by
|
|
``BEGIN IMMEDIATE``. In WAL mode, entering exclusive locking mode
|
|
requires exclusive locks on the WAL index, so any other open connection —
|
|
reader or writer — makes it fail with SQLITE_BUSY. Neither statement
|
|
parses the schema, so this works on the malformed databases repair exists
|
|
to handle.
|
|
|
|
Fails **open** (returns False) on anything other than a positive
|
|
busy/locked signal: refusing to repair a database that nobody is actually
|
|
holding would strand the very self-heal path this guard protects.
|
|
|
|
Scope: the WAL-index exclusive lock is what makes this detect a holder, so
|
|
the guard is effective in WAL mode. On SQLite builds carrying the WAL-reset
|
|
bug and on NFS/SMB, Hermes deliberately runs ``state.db`` in
|
|
``journal_mode=DELETE`` (see :func:`apply_wal_with_fallback`); there a held
|
|
reader takes only a SHARED lock, ``BEGIN IMMEDIATE`` still acquires
|
|
RESERVED, and this probe returns False. In that mode repair is serialised
|
|
only by the cross-process repairer lock rather than by this holder probe.
|
|
The 2026-08 incident that motivated the guard was in WAL mode, which this
|
|
covers; broadening detection to DELETE mode is left to a follow-up.
|
|
"""
|
|
probe = None
|
|
try:
|
|
probe = _connect_repair_durable(db_path, timeout=0.0)
|
|
probe.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
probe.execute("BEGIN IMMEDIATE")
|
|
probe.execute("ROLLBACK")
|
|
return False
|
|
except sqlite3.OperationalError as exc:
|
|
lowered = str(exc).lower()
|
|
return "locked" in lowered or "busy" in lowered
|
|
except sqlite3.DatabaseError:
|
|
# Malformed/unreadable: no evidence of a live holder either way.
|
|
return False
|
|
except Exception:
|
|
return False
|
|
finally:
|
|
if probe is not None:
|
|
try:
|
|
# Drop exclusive locking mode before closing so the probe
|
|
# itself never leaves the file pinned.
|
|
probe.execute("PRAGMA locking_mode=NORMAL")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
probe.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
|
|
"""Repair a state.db whose ``sqlite_master`` schema is malformed or whose
|
|
FTS indexes reject writes.
|
|
|
|
Handles two corruption classes: the "duplicate object definition" /
|
|
malformed-schema class where even ``PRAGMA`` statements fail, and the FTS
|
|
write-corruption class (#50502) where base tables read fine and
|
|
``integrity_check`` passes but writes fail through the ``messages_fts*``
|
|
triggers. Tries least-destructive recovery first and escalates:
|
|
|
|
1. **Rebuild FTS indexes in place** via the FTS5 ``'rebuild'`` command,
|
|
which rewrites the internal b-tree segments from the canonical
|
|
``messages`` rows without dropping or recreating anything. Fixes the
|
|
FTS write-corruption class while preserving the schema intact.
|
|
2. **De-duplicate** ``sqlite_master`` (keep the lowest rowid per
|
|
``type``/``name``). Fixes the canonical "table X already exists"
|
|
case and PRESERVES the existing FTS index intact.
|
|
3. **Drop the FTS schema** (every ``messages_fts*`` object) + ``VACUUM``.
|
|
The next ``SessionDB()`` open rebuilds the FTS indexes from the
|
|
canonical ``messages`` table.
|
|
|
|
Canonical ``sessions`` / ``messages`` rows are never modified by a failed
|
|
attempt. Mutating strategies run against a complete SQLite snapshot and a
|
|
successful result is copied back transactionally. A timestamped raw backup
|
|
is taken first unless ``backup=False``.
|
|
|
|
The surgery below is serialised across processes (see
|
|
:func:`_cross_process_repair_lock`): the gateway service, the Desktop
|
|
app's backend and interactive CLI sessions all open the same file, and
|
|
two of them running ``writable_schema`` surgery concurrently is itself a
|
|
corruption source.
|
|
|
|
Returns a report dict: ``{repaired: bool, strategy: str|None,
|
|
backup_path: str|None, error: str|None}``.
|
|
"""
|
|
report: Dict[str, Any] = {
|
|
"repaired": False,
|
|
"strategy": None,
|
|
"backup_path": None,
|
|
"error": None,
|
|
}
|
|
|
|
# Startup-watchdog progress lease: repair (raw backup copy + surgery +
|
|
# VACUUM) is I/O-bound — near-zero CPU on a multi-GB file — which the
|
|
# watchdog's CPU fallback would misread as a parked deadlock (OOF-298).
|
|
# Single lease is deliberate (clamped to _MAX_LEASE_S=900): honest worst
|
|
# case is up to the lease duration of zombie time on a wedged repair,
|
|
# accepted over per-chunk renewal complexity in the repair loop.
|
|
report_startup_progress(900.0, phase="state_db_repair")
|
|
|
|
db_path = Path(db_path)
|
|
if not db_path.exists():
|
|
report["error"] = f"{db_path} does not exist"
|
|
return report
|
|
|
|
# Cross-restart attempt cap (#86747): the in-memory claim bounds one
|
|
# process, but a corruption class the strategies below cannot heal
|
|
# (b-tree page damage) previously re-ran the whole surgery — and took a
|
|
# fresh multi-hundred-MB forensic backup — on EVERY restart, forever.
|
|
# After _MAX_PERSISTENT_REPAIR_ATTEMPTS failures against the same
|
|
# damaged file, stop retrying and surface a terminal, actionable error.
|
|
if _persistent_repair_attempts_exhausted(db_path):
|
|
report["error"] = _persistent_repair_exhausted_error(db_path)
|
|
logger.error("state.db repair skipped: %s", report["error"])
|
|
return report
|
|
|
|
result = report
|
|
with _cross_process_repair_lock(db_path) as holding_lock:
|
|
if not holding_lock:
|
|
# Another process is still inside its critical section. It may
|
|
# nonetheless have healed the file already (long VACUUM after a
|
|
# successful strategy), so re-probe before reporting failure.
|
|
if _db_opens_cleanly(db_path) is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "repaired_by_other_process"
|
|
else:
|
|
report["error"] = (
|
|
"another process holds the state.db repair lock; skipped "
|
|
"schema surgery to avoid racing it"
|
|
)
|
|
else:
|
|
# The fast check above avoids taking the lock for a known-exhausted
|
|
# image. Recheck after acquisition: a queued repairer can have
|
|
# recorded the final failure while this process waited, and this
|
|
# process must not start a fourth attempt.
|
|
if _persistent_repair_attempts_exhausted(db_path):
|
|
report["error"] = _persistent_repair_exhausted_error(db_path)
|
|
logger.error("state.db repair skipped: %s", report["error"])
|
|
# Keep the existing WAL-holder preflight: it preserves the
|
|
# established fail-closed behaviour for active readers before we
|
|
# create a forensic backup. It is not the race defence; the
|
|
# retained exclusive guard inside the locked routine is what
|
|
# excludes writers continuously through promotion. DELETE-mode
|
|
# readers which this probe cannot see are still rejected by the
|
|
# later BEGIN EXCLUSIVE acquisition.
|
|
elif _live_writer_holds_db(db_path):
|
|
report["error"] = (
|
|
"a live writer still holds state.db; skipped schema surgery "
|
|
"to avoid tearing b-tree pages under a concurrent writer. "
|
|
"Stop the gateway (hermes gateway stop) and retry."
|
|
)
|
|
logger.error("state.db repair skipped: %s", report["error"])
|
|
else:
|
|
# Probe the mode BEFORE surgery (#89674): every repair
|
|
# strategy rewrites the file, and a rebuilt SQLite file comes
|
|
# back in the default journal mode (delete) — silently moving
|
|
# a WAL store out of WAL with nothing in the logs recording
|
|
# the flip. The open-time WAL-reset gate never sees this flip
|
|
# because it happens inside the repair path (distinct from
|
|
# the open-time flip #89393 warns about). A probe of the
|
|
# damaged file may fail, in which case the canonical
|
|
# database.journal_mode setting is the restore target.
|
|
before_mode = _probe_journal_mode_for_repair(db_path)
|
|
result = _repair_state_db_schema_locked(
|
|
db_path, backup=backup, report=report
|
|
)
|
|
if result.get("repaired"):
|
|
result["journal_mode_before"] = before_mode
|
|
_restore_journal_mode_after_repair(db_path, before_mode)
|
|
# Environmental aborts happen before a strategy gets to mutate the
|
|
# isolated snapshot. They are retriable operating conditions, not
|
|
# proof that the damaged database exhausted a repair strategy.
|
|
# Keep that private signal out of the public report while
|
|
# successful health checks still clear a stale persistent failure
|
|
# record. This ledger update stays under the same cross-process
|
|
# lock as surgery, so two repairers cannot lose each other's
|
|
# attempt updates. A queued loser must not record at all: its
|
|
# owner is responsible for its outcome.
|
|
attempted = bool(result.pop("_repair_attempted", False))
|
|
if attempted or result.get("repaired"):
|
|
_record_repair_outcome(
|
|
db_path, repaired=bool(result.get("repaired"))
|
|
)
|
|
return result
|
|
|
|
|
|
def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
|
|
"""Best-effort journal-mode probe for a (possibly malformed) DB file.
|
|
|
|
Returns the on-disk mode (``wal``/``delete``), or ``None`` when the file
|
|
cannot be opened or probed — a malformed header or a concurrent opener's
|
|
locks are both expected on the repair path. Callers fall back to the
|
|
configured ``database.journal_mode`` for ``None``.
|
|
"""
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
return _on_disk_journal_mode(conn)
|
|
finally:
|
|
conn.close()
|
|
except (sqlite3.Error, OSError):
|
|
return None
|
|
|
|
|
|
def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str]) -> None:
|
|
"""Re-apply the journal mode after schema surgery (#89674).
|
|
|
|
A repaired/rebuilt SQLite file comes back in the default journal mode
|
|
(delete). Without this restore, a corruption event deterministically
|
|
moves a WAL store out of WAL and nothing records the change — the
|
|
WAL-reset gate at open time never sees the flip because it happened
|
|
inside the repair path, not at open (the open-time flip #89393 warns
|
|
about is a different door).
|
|
|
|
The restore runs through :func:`apply_wal_with_fallback` — the canonical
|
|
journal-mode path — rather than issuing a switch pragma directly, so it
|
|
inherits the vulnerable-SQLite WAL-reset gate (a rebuilt file IS a new
|
|
database: on a vulnerable runtime the gate deliberately keeps it in
|
|
DELETE, and "restore could not reach WAL" there is the expected outcome,
|
|
not a failure), the macOS-NFS silent-refusal handling, and the WAL
|
|
companions (size limit, checkpoint barrier, synchronous=FULL) that the
|
|
front door applies. ``before_mode`` is the pre-surgery probe (None when
|
|
the damaged file could not be probed) and is only used for the log
|
|
comparison — the restore target itself is whatever the canonical path
|
|
resolves from ``database.journal_mode``.
|
|
|
|
Best-effort by design: the repair itself already succeeded, so failures
|
|
to re-apply are logged at WARNING, never raised.
|
|
"""
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
after = apply_wal_with_fallback(conn, db_label=db_path.name)
|
|
finally:
|
|
conn.close()
|
|
if before_mode and after != before_mode:
|
|
logger.warning(
|
|
"state.db repair changed journal_mode %r -> %r "
|
|
"(pre-surgery probe %r; restore resolved through "
|
|
"apply_wal_with_fallback per database.journal_mode and the "
|
|
"WAL-reset gate)",
|
|
before_mode, after, before_mode,
|
|
)
|
|
except (sqlite3.Error, OSError) as exc:
|
|
logger.warning(
|
|
"state.db repair at %s: post-surgery journal-mode restore "
|
|
"failed (%s); verify with PRAGMA journal_mode on the next open",
|
|
db_path, exc,
|
|
)
|
|
|
|
|
|
def _repair_state_db_schema_locked(
|
|
db_path: Path, *, backup: bool, report: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Repair strategies for :func:`repair_state_db_schema`.
|
|
|
|
Caller must hold the cross-process repair lock for *db_path*.
|
|
|
|
The strategies run on a SCRATCH COPY and the result is copied back through
|
|
SQLite's transactional backup API only once it is proven to open cleanly.
|
|
A repair that does not succeed therefore cannot modify or lose committed
|
|
canonical data. In WAL mode SQLite may checkpoint already-committed WAL
|
|
frames into the main file while the exclusive guard is released; that is
|
|
not a repair mutation and does not change the committed database image.
|
|
|
|
They used to run in place, and Strategy 2 ends in ``VACUUM``. VACUUM does
|
|
not preserve what it cannot parse: it rebuilds the file from the schema
|
|
SQLite can still read, so when the damage IS in the schema b-tree — page
|
|
1's child pointers resolving to data pages, which is exactly the
|
|
``malformed database schema ()`` class this function exists to handle —
|
|
every table hanging off the unreadable part is silently dropped. The probe
|
|
afterwards then correctly reports the file is STILL malformed, so the
|
|
function returns ``repaired=False`` and advises a manual restore, having
|
|
already destroyed the thing it was asked to save. Destroying the data and
|
|
reporting the repair failed are not mutually exclusive outcomes, and
|
|
nothing here treated them as a contradiction.
|
|
|
|
The pre-repair backup (#69603) does not close this: it is a forensic
|
|
artefact that nothing reads back, so recovery still depends on a human
|
|
noticing a ``.malformed-backup-*`` file and knowing what to do with it.
|
|
Not mutating the original in the first place is the property that holds
|
|
without a human in the loop.
|
|
"""
|
|
scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
|
|
cleanup_error = _unlink_db_triple(scratch)
|
|
if cleanup_error is not None:
|
|
report["error"] = (
|
|
"could not remove a stale repair snapshot before probing state.db: "
|
|
f"{cleanup_error}"
|
|
)
|
|
logger.error("state.db repair aborted: %s", report["error"])
|
|
return report
|
|
|
|
# Re-probe under the lock: a process we queued behind may have just
|
|
# repaired the file, in which case redoing the surgery would undo its
|
|
# work on a now-healthy DB (the repair/re-corrupt cascade this lock
|
|
# exists to break).
|
|
if _db_opens_cleanly(db_path) is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "already_healthy"
|
|
return report
|
|
|
|
if backup:
|
|
bpath, backup_error = _backup_db_file(db_path)
|
|
report["backup_path"] = str(bpath) if bpath else None
|
|
if bpath is None:
|
|
# HARD STOP (#69603). The forensic recovery image remains required
|
|
# when corruption defeats every strategy, even though the
|
|
# strategies themselves now run against an isolated snapshot.
|
|
report["error"] = (
|
|
"pre-repair backup refused; aborting schema repair to avoid "
|
|
f"mutating the only copy of the damaged DB: {backup_error}"
|
|
)
|
|
logger.error("state.db repair aborted: %s", report["error"])
|
|
return report
|
|
|
|
# The forensic copy intentionally happens before this guard: its raw-copy
|
|
# safety checks inspect real live holders and would be poisoned by our
|
|
# exclusive connection. Everything that can affect the repair image or
|
|
# live promotion happens only after writer exclusion is held.
|
|
with _exclusive_repair_db_guard(db_path) as (live_guard, guard_error):
|
|
if live_guard is None:
|
|
report["error"] = (
|
|
"could not acquire exclusive state.db repair ownership; "
|
|
"skipped schema surgery to avoid overwriting a concurrent "
|
|
f"writer. Stop the gateway and retry: {guard_error}"
|
|
)
|
|
if guard_error is not None and _repair_failure_consumes_attempt(
|
|
guard_error
|
|
):
|
|
report["_repair_attempted"] = True
|
|
logger.error("state.db repair skipped: %s", report["error"])
|
|
return report
|
|
|
|
space_error = _repair_scratch_space_error(db_path)
|
|
if space_error is not None:
|
|
report["error"] = space_error
|
|
logger.error("state.db repair aborted: %s", report["error"])
|
|
return report
|
|
|
|
try:
|
|
# Reuse live_guard rather than opening a second source connection:
|
|
# the guard owns the exclusion, so a second connection could be
|
|
# blocked by our own EXCLUSIVE lock on some SQLite builds.
|
|
_copy_database_snapshot(
|
|
db_path, scratch, source_connection=live_guard
|
|
)
|
|
except (OSError, sqlite3.Error, TimeoutError) as exc:
|
|
report["error"] = (
|
|
f"could not stage a complete SQLite repair snapshot of {db_path}: {exc}"
|
|
)
|
|
if _repair_failure_consumes_attempt(exc):
|
|
report["_repair_attempted"] = True
|
|
logger.error("state.db repair aborted: %s", report["error"])
|
|
_unlink_db_triple(scratch)
|
|
return report
|
|
|
|
try:
|
|
# This private marker is consumed by the outer wrapper. A strategy
|
|
# failure is a genuine repair outcome and consumes the persistent
|
|
# budget. A later promotion failure is classified separately:
|
|
# full disks, I/O, permission and lock failures are environmental
|
|
# aborts, not evidence that the strategy cannot repair this image.
|
|
report["_repair_attempted"] = True
|
|
_run_repair_strategies(scratch, report)
|
|
if report.get("repaired"):
|
|
try:
|
|
# Do not os.replace the live DB: Windows rejects
|
|
# replacement under open handles, while POSIX would leave
|
|
# those handles on the old inode. The same transaction-
|
|
# free guard that staged the live image receives the
|
|
# promotion, retaining writer exclusion throughout.
|
|
_copy_database_snapshot(
|
|
scratch,
|
|
db_path,
|
|
destination_connection=live_guard,
|
|
)
|
|
except (OSError, sqlite3.Error, TimeoutError) as exc:
|
|
report["repaired"] = False
|
|
report["strategy"] = None
|
|
report["_repair_attempted"] = _repair_failure_consumes_attempt(
|
|
exc
|
|
)
|
|
report["error"] = (
|
|
"repaired snapshot could not be promoted transactionally: "
|
|
f"{exc}"
|
|
)
|
|
logger.error("state.db repair promotion failed: %s", exc)
|
|
else:
|
|
logger.warning(
|
|
"state.db repaired via '%s' and promoted transactionally: %s",
|
|
report.get("strategy"),
|
|
db_path,
|
|
)
|
|
if not report.get("repaired"):
|
|
# Logged HERE, not inside the strategies: they run against the
|
|
# scratch copy, and naming that throwaway path in the one
|
|
# message a human is meant to act on would send them to a file
|
|
# that no longer exists by the time they read it.
|
|
logger.error(
|
|
"state.db schema repair could not recover %s automatically "
|
|
"(no committed canonical data was modified or lost; backup: %s); "
|
|
"manual restore from backup may be required.",
|
|
db_path,
|
|
report["backup_path"],
|
|
)
|
|
return report
|
|
finally:
|
|
# Never leave a half-repaired file beside the DB for a later probe
|
|
# — or a later human — to mistake for the real thing.
|
|
cleanup_error = _unlink_db_triple(scratch)
|
|
if cleanup_error is not None:
|
|
logger.warning(
|
|
"Could not remove state.db repair snapshot after repair: %s",
|
|
cleanup_error,
|
|
)
|
|
|
|
|
|
def _unlink_db_triple(path: Path) -> Optional[str]:
|
|
"""Remove *path* and every SQLite sidecar; return any cleanup failure."""
|
|
failures: List[str] = []
|
|
for suffix in ("", *_DB_SIDECAR_SUFFIXES):
|
|
victim = path if not suffix else path.with_name(path.name + suffix)
|
|
for attempt in range(10):
|
|
try:
|
|
victim.unlink()
|
|
break
|
|
except FileNotFoundError:
|
|
break
|
|
except PermissionError as exc:
|
|
# Windows may retain a just-closed SQLite handle for a few
|
|
# scheduler ticks. Bound the retry; a later backup open still
|
|
# fails safely if the handle truly remains live.
|
|
if _IS_WINDOWS and attempt < 9:
|
|
time.sleep(0.05)
|
|
continue
|
|
failures.append(f"{victim}: {exc}")
|
|
break
|
|
except OSError as exc:
|
|
failures.append(f"{victim}: {exc}")
|
|
break
|
|
return "; ".join(failures) or None
|
|
|
|
|
|
def _run_repair_strategies(
|
|
db_path: Path, report: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Escalating repair attempts, applied to *db_path* IN PLACE.
|
|
|
|
Every strategy here mutates its argument — FTS rebuild, REINDEX,
|
|
``writable_schema`` surgery, ``VACUUM``. It is therefore only ever called
|
|
by :func:`_repair_state_db_schema_locked` on a scratch copy that nothing
|
|
else holds open, never on the user's database.
|
|
"""
|
|
# ── Strategy 0: rebuild FTS indexes in place (FTS write-corruption) ──
|
|
# The FTS5 'rebuild' command rewrites the internal index from the canonical
|
|
# content table. This is the recommended, least-destructive recovery for a
|
|
# corrupt FTS index that rejects message writes while reads still succeed.
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
# The cjk index can only be rebuilt with its tokenizer loaded;
|
|
# best-effort (a tokenizer-less host skips it at the probe below).
|
|
load_fts5_cjk_extension(conn)
|
|
for table_name in (
|
|
"messages_fts", "messages_fts_trigram", "messages_fts_cjk"
|
|
):
|
|
try:
|
|
conn.execute(
|
|
f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')"
|
|
)
|
|
except sqlite3.OperationalError:
|
|
# Table absent (FTS disabled / trigram off / cjk not
|
|
# present or tokenizer unavailable) — skip it.
|
|
continue
|
|
finally:
|
|
conn.close()
|
|
if _db_opens_cleanly(db_path) is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "rebuild_fts"
|
|
logger.warning(
|
|
"state.db FTS indexes rebuilt in place (schema preserved): %s",
|
|
db_path,
|
|
)
|
|
return report
|
|
except sqlite3.DatabaseError as exc:
|
|
logger.warning("state.db FTS in-place rebuild pass failed: %s", exc)
|
|
|
|
# ── Strategy 0.5: rebuild stale B-tree indexes (#63386) ──
|
|
# PRAGMA integrity_check can report "wrong # of entries in index" when a
|
|
# B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
|
|
# base table. REINDEX rewrites the index b-tree from the canonical table
|
|
# rows using the existing index definition, fixing the mismatch without
|
|
# touching data or FTS schema.
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
# REINDEX rewrites every index b-tree; take the barriers now that
|
|
# the schema parses, in case the open-time attempt was refused.
|
|
_reapply_durability_barriers(conn)
|
|
conn.execute("REINDEX")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
if _db_opens_cleanly(db_path) is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "reindex_btree"
|
|
logger.warning(
|
|
"state.db B-tree indexes rebuilt via REINDEX: %s", db_path
|
|
)
|
|
return report
|
|
except sqlite3.DatabaseError as exc:
|
|
logger.warning("state.db REINDEX pass failed: %s", exc)
|
|
|
|
# ── Strategy 1: de-duplicate sqlite_master (keeps FTS index) ──
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
conn.execute("PRAGMA writable_schema=ON")
|
|
dupes = conn.execute(
|
|
"SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep "
|
|
"FROM sqlite_master GROUP BY type, name HAVING c > 1"
|
|
).fetchall()
|
|
for type_, name, _count, keep in dupes:
|
|
conn.execute(
|
|
"DELETE FROM sqlite_master "
|
|
"WHERE type IS ? AND name IS ? AND rowid <> ?",
|
|
(type_, name, keep),
|
|
)
|
|
if dupes:
|
|
_bump_schema_cookie(conn)
|
|
conn.execute("PRAGMA writable_schema=OFF")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
if _db_opens_cleanly(db_path) is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "dedup_schema"
|
|
logger.warning(
|
|
"state.db schema repaired by de-duplicating sqlite_master "
|
|
"(FTS index preserved): %s", db_path
|
|
)
|
|
return report
|
|
except sqlite3.DatabaseError as exc:
|
|
logger.warning("state.db dedup repair pass failed: %s", exc)
|
|
|
|
# ── Strategy 2: drop all FTS schema, VACUUM, rebuild on next open ──
|
|
#
|
|
# The destructive one, and the reason this whole path now runs on a
|
|
# scratch copy. VACUUM rebuilds the file from the schema SQLite can still
|
|
# parse, so on a damaged schema b-tree it silently drops every table
|
|
# hanging off the unreadable part — and the probe below then correctly
|
|
# reports the result is still malformed. On a scratch copy that is merely
|
|
# a discarded attempt; on the live file it was data loss.
|
|
try:
|
|
conn = _connect_repair_durable(db_path)
|
|
try:
|
|
conn.execute("PRAGMA writable_schema=ON")
|
|
conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'")
|
|
_bump_schema_cookie(conn)
|
|
conn.execute("PRAGMA writable_schema=OFF")
|
|
conn.commit()
|
|
# The schema is repaired and parseable now, so the barriers can
|
|
# finally stick — and VACUUM, which rewrites the entire file, is
|
|
# the single most damaging operation to lose halfway.
|
|
_reapply_durability_barriers(conn)
|
|
conn.execute("VACUUM")
|
|
finally:
|
|
conn.close()
|
|
reason = _db_opens_cleanly(db_path)
|
|
if reason is None:
|
|
report["repaired"] = True
|
|
report["strategy"] = "drop_fts_rebuild"
|
|
logger.warning(
|
|
"state.db schema repaired by dropping FTS schema; indexes "
|
|
"will rebuild from messages on next open: %s", db_path
|
|
)
|
|
return report
|
|
report["error"] = reason
|
|
except sqlite3.DatabaseError as exc:
|
|
report["error"] = str(exc)
|
|
|
|
# The "could not recover" log lives in the caller: it must name the user's
|
|
# database, not the scratch copy these strategies were handed.
|
|
return report
|
|
|
|
|
|
# ── 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.
|
|
"""
|
|
|
|
|
|
# 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."
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
def _read_sqlite_application_id(db_path: Path) -> "Optional[int]":
|
|
"""Read application_id from the SQLite header without opening a connection."""
|
|
try:
|
|
with db_path.open("rb") as handle:
|
|
header = handle.read(_STATE_DB_APPLICATION_ID_OFFSET + 4)
|
|
except OSError:
|
|
return None
|
|
if len(header) < _STATE_DB_APPLICATION_ID_OFFSET + 4:
|
|
return None
|
|
if header[:16] != b"SQLite format 3\x00":
|
|
return None
|
|
return int(
|
|
struct.unpack(
|
|
">I",
|
|
header[_STATE_DB_APPLICATION_ID_OFFSET:_STATE_DB_APPLICATION_ID_OFFSET + 4],
|
|
)[0]
|
|
)
|
|
|
|
|
|
def _stat_db_file_identity(path: Path) -> "Optional[tuple]":
|
|
"""Return ``(st_dev, st_ino)`` for *path*, or None when identity is unavailable."""
|
|
try:
|
|
st = os.stat(path)
|
|
except OSError:
|
|
return None
|
|
# Windows volumes (and some network FS) report st_ino=0; a (0, 0)
|
|
# identity would false-positive every check. Skip the inode half of
|
|
# the guard there; generation stamp still applies.
|
|
if not st.st_dev or not st.st_ino:
|
|
return None
|
|
return (st.st_dev, st.st_ino)
|
|
|
|
|
|
def _connect_tracked_db(path, tracking_path=None, **kwargs):
|
|
"""``sqlite3.connect`` that registers the open fd for lock-safety.
|
|
|
|
While a connection is live, byte-level probes of the same file are
|
|
refused: an ``open()``/``close()`` cancels every POSIX advisory lock this
|
|
process holds on it -- including a running VACUUM's EXCLUSIVE lock.
|
|
Released automatically on ``close()``.
|
|
|
|
The ONLY tolerated fallback is the helper being absent entirely
|
|
(scaffold/embed installs that ship hermes_state without hermes_cli). A
|
|
real connection failure must propagate: silently retrying an *untracked*
|
|
connect would disable the guard for the lifetime of that connection,
|
|
which is precisely the failure mode this module exists to prevent.
|
|
"""
|
|
try:
|
|
from hermes_cli.sqlite_safe_read import connect_tracked
|
|
except ImportError:
|
|
logger.debug(
|
|
"hermes_cli.sqlite_safe_read unavailable; opening %s untracked "
|
|
"(byte-probe guard inactive in this install)",
|
|
path,
|
|
)
|
|
return sqlite3.connect(str(path), **kwargs)
|
|
|
|
# Open through THIS module's sqlite3.connect so callers (and tests) that
|
|
# patch hermes_state.sqlite3.connect keep control of connection creation;
|
|
# the helper still owns tracking.
|
|
return connect_tracked(
|
|
path,
|
|
tracking_path=tracking_path,
|
|
connect_fn=sqlite3.connect,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def is_zeroed_state_db(
|
|
path: Path, *, probe_bytes: int = 100, force: bool = False
|
|
) -> bool:
|
|
"""Detect the #68474/#97568 zeroed state.db signature (0-byte or NUL header).
|
|
|
|
Byte-level probe, so it is only safe BEFORE any connection to *path*
|
|
exists in this process: ``close()`` cancels every POSIX advisory lock the
|
|
process holds on the file, which can pull the EXCLUSIVE lock out from
|
|
under a running VACUUM and corrupt the database. The read is routed
|
|
through ``read_header_bytes_preopen``, which refuses (returning False
|
|
here) once a connection is live. Pass ``force=True`` only for offline
|
|
files -- quarantined copies, snapshots, archives.
|
|
|
|
Prefer ``hermes_cli.backup.is_zeroed_sqlite_file`` when available; this
|
|
local copy keeps SessionDB openable without importing the CLI package
|
|
in constrained embed paths.
|
|
"""
|
|
try:
|
|
from hermes_cli.backup import is_zeroed_sqlite_file
|
|
|
|
return is_zeroed_sqlite_file(path, probe_bytes=probe_bytes, force=force)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if not path.is_file():
|
|
# Special files (FIFO, device, socket) are never "zeroed", and
|
|
# probing a FIFO would block until a writer appears.
|
|
return False
|
|
size = path.stat().st_size
|
|
except OSError:
|
|
return False
|
|
if size < 0:
|
|
return False
|
|
from hermes_cli.sqlite_safe_read import has_live_connection, read_header_bytes_preopen
|
|
|
|
if not force and has_live_connection(path):
|
|
return False
|
|
|
|
head = read_header_bytes_preopen(
|
|
path, length=max(16, probe_bytes), force=force
|
|
)
|
|
if head is None:
|
|
return False
|
|
if len(head) == 0:
|
|
return True
|
|
if head.startswith(b"SQLite format 3"):
|
|
return False
|
|
return all(byte == 0 for byte in head)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def quarantine_cross_process_lock(path: Path, timeout: float = 5.0):
|
|
"""Acquire the cross-process lock for path.quarantine.lock."""
|
|
import platform
|
|
|
|
lock_path = path.with_name(path.name + ".quarantine.lock")
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = lock_path.open("a+b")
|
|
acquired = False
|
|
try:
|
|
deadline = time.monotonic() + timeout
|
|
if platform.system() == "Windows":
|
|
import msvcrt
|
|
|
|
while True:
|
|
try:
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
acquired = True
|
|
break
|
|
except OSError:
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
time.sleep(0.020)
|
|
else:
|
|
import fcntl
|
|
|
|
while True:
|
|
try:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
acquired = True
|
|
break
|
|
except (BlockingIOError, OSError):
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
time.sleep(0.020)
|
|
yield acquired
|
|
finally:
|
|
try:
|
|
if acquired:
|
|
if platform.system() == "Windows":
|
|
import msvcrt
|
|
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
except (OSError, AttributeError):
|
|
pass
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def quarantine_zeroed_state_db(
|
|
path: Path, *, already_locked: bool = False
|
|
) -> Optional[Path]:
|
|
"""Move a zeroed state.db aside (preserve bytes) and return quarantine path.
|
|
|
|
Uses a cross-process lock (``#68805``) so two concurrent startups cannot
|
|
race: the first process moves the zeroed file and the second re-checks
|
|
under the lock, finding the file already gone (or a fresh DB in its place)
|
|
instead of clobbering the quarantine.
|
|
"""
|
|
def _do_quarantine():
|
|
if not path.exists():
|
|
logger.info(
|
|
"quarantine_zeroed_state_db: %s already moved by another process",
|
|
path,
|
|
)
|
|
return None
|
|
if not is_zeroed_state_db(path):
|
|
logger.info(
|
|
"quarantine_zeroed_state_db: %s is no longer zeroed (another "
|
|
"process quarantined it and a fresh DB was created)",
|
|
path,
|
|
)
|
|
return None
|
|
|
|
try:
|
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
except Exception:
|
|
ts = "unknown"
|
|
dest = path.with_name(
|
|
f"{path.name}.zeroed-{ts}-{os.getpid()}.bak"
|
|
)
|
|
n = 0
|
|
while dest.exists():
|
|
n += 1
|
|
dest = path.with_name(
|
|
f"{path.name}.zeroed-{ts}-{os.getpid()}-{n}.bak"
|
|
)
|
|
try:
|
|
path.rename(dest)
|
|
except OSError as exc:
|
|
logger.error("Failed to quarantine zeroed %s: %s", path, exc)
|
|
return None
|
|
for suffix in ("-wal", "-shm"):
|
|
side = Path(str(path) + suffix)
|
|
if side.exists():
|
|
try:
|
|
side.rename(Path(str(dest) + suffix))
|
|
except OSError:
|
|
pass
|
|
return dest
|
|
|
|
if already_locked:
|
|
return _do_quarantine()
|
|
|
|
with quarantine_cross_process_lock(path) as acquired:
|
|
if not acquired:
|
|
logger.error(
|
|
"quarantine lock for %s not acquired within 5s — refusing to "
|
|
"quarantine without the cross-process lock. The zeroed file "
|
|
"is left in place. If sessions fail to load, restore from "
|
|
"state-snapshots via `hermes snapshot list` / "
|
|
"`hermes snapshot restore <id>`.",
|
|
path,
|
|
)
|
|
return None
|
|
return _do_quarantine()
|
|
|
|
|
|
# ── Read-only health/stats probes (hermes doctor, dashboards) ──────────
|
|
|
|
|
|
def collect_state_db_stats(db_path: Path) -> Dict[str, Any]:
|
|
"""Best-effort, strictly read-only stats snapshot of a state.db file.
|
|
|
|
Opens the database with ``mode=ro`` (URI) and a short timeout so it can
|
|
run against a *live* database held by a gateway without ever taking a
|
|
write lock or mutating the file. Every field is collected independently:
|
|
a failed pragma/SELECT yields ``None`` for that field, and the helper
|
|
itself never raises.
|
|
|
|
Deliberately does NOT instantiate :class:`SessionDB` — its constructor
|
|
runs schema DDL (migrations, FTS table creation), which is exactly the
|
|
kind of write a diagnostics probe must never perform.
|
|
|
|
Returned keys (all present, any may be None on failure):
|
|
|
|
- ``page_count``, ``page_size``, ``freelist_count`` — PRAGMA values
|
|
- ``logical_size_bytes`` — page_count * page_size (post-checkpoint size)
|
|
- ``wal_size_bytes`` — stat() of ``<db>-wal`` (0 when absent)
|
|
- ``journal_mode`` — PRAGMA journal_mode string
|
|
- ``messages`` / ``sessions`` — row counts
|
|
- ``fts_tables`` — dict of {table_name: bool} presence for
|
|
messages_fts / messages_fts_trigram / messages_fts_cjk
|
|
- ``fts_storage_version`` — int from state_meta, None when the marker is
|
|
absent (legacy pre-v23 inline layout)
|
|
- ``fts_rebuild_pending`` — True when the deferred v23 backfill has not
|
|
finished (high_water present and progress < high_water)
|
|
- ``fts_rebuild_high_water`` / ``fts_rebuild_progress`` — raw ints
|
|
- ``fts_rebuild_deferral`` — durable blocked-repair diagnostic, when present
|
|
"""
|
|
stats: Dict[str, Any] = {
|
|
"page_count": None,
|
|
"page_size": None,
|
|
"freelist_count": None,
|
|
"logical_size_bytes": None,
|
|
"wal_size_bytes": None,
|
|
"journal_mode": None,
|
|
"messages": None,
|
|
"sessions": None,
|
|
"fts_tables": None,
|
|
"fts_storage_version": None,
|
|
"fts_rebuild_pending": None,
|
|
"fts_rebuild_high_water": None,
|
|
"fts_rebuild_progress": None,
|
|
"fts_rebuild_deferral": None,
|
|
}
|
|
|
|
# WAL sidecar size needs no connection at all.
|
|
try:
|
|
wal_path = Path(str(db_path) + "-wal")
|
|
stats["wal_size_bytes"] = wal_path.stat().st_size if wal_path.exists() else 0
|
|
except OSError:
|
|
pass
|
|
|
|
conn = None
|
|
try:
|
|
# mode=ro refuses to create the file and refuses every write; a
|
|
# short timeout keeps doctor snappy when a writer holds the lock.
|
|
# Route through the tracked connect so byte-probe helpers
|
|
# (read_header_bytes_preopen) see this connection and refuse raw
|
|
# opens that could cancel our POSIX locks mid-read.
|
|
conn = _connect_tracked_db(
|
|
f"file:{Path(db_path)}?mode=ro",
|
|
tracking_path=Path(db_path),
|
|
uri=True,
|
|
timeout=2.0,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("collect_state_db_stats: cannot open %s read-only: %s",
|
|
db_path, exc)
|
|
return stats
|
|
|
|
def _scalar(sql: str) -> Any:
|
|
try:
|
|
row = conn.execute(sql).fetchone()
|
|
return row[0] if row else None
|
|
except Exception:
|
|
return None
|
|
|
|
try:
|
|
pc = _scalar("PRAGMA page_count")
|
|
ps = _scalar("PRAGMA page_size")
|
|
stats["page_count"] = int(pc) if pc is not None else None
|
|
stats["page_size"] = int(ps) if ps is not None else None
|
|
if stats["page_count"] is not None and stats["page_size"] is not None:
|
|
stats["logical_size_bytes"] = stats["page_count"] * stats["page_size"]
|
|
|
|
fl = _scalar("PRAGMA freelist_count")
|
|
stats["freelist_count"] = int(fl) if fl is not None else None
|
|
|
|
jm = _scalar("PRAGMA journal_mode")
|
|
stats["journal_mode"] = str(jm) if jm is not None else None
|
|
|
|
msgs = _scalar("SELECT COUNT(*) FROM messages")
|
|
stats["messages"] = int(msgs) if msgs is not None else None
|
|
sess = _scalar("SELECT COUNT(*) FROM sessions")
|
|
stats["sessions"] = int(sess) if sess is not None else None
|
|
|
|
# FTS table presence via sqlite_master (never SELECTs from the
|
|
# virtual tables themselves — a corrupt index must not fail stats).
|
|
try:
|
|
names = {
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
|
"AND name IN (?, ?, ?)",
|
|
("messages_fts", "messages_fts_trigram", "messages_fts_cjk"),
|
|
).fetchall()
|
|
}
|
|
stats["fts_tables"] = {
|
|
t: (t in names)
|
|
for t in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk")
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Raw state_meta reads — cheap, and independent of SessionDB.
|
|
def _meta_int(key: str) -> Optional[int]:
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT value FROM state_meta WHERE key = ?", (key,)
|
|
).fetchone()
|
|
return int(row[0]) if row and row[0] is not None else None
|
|
except Exception:
|
|
return None
|
|
|
|
stats["fts_storage_version"] = _meta_int("fts_storage_version")
|
|
high_water = _meta_int("fts_rebuild_high_water")
|
|
progress = _meta_int("fts_rebuild_progress")
|
|
stats["fts_rebuild_high_water"] = high_water
|
|
stats["fts_rebuild_progress"] = progress
|
|
if high_water is None:
|
|
stats["fts_rebuild_pending"] = False
|
|
else:
|
|
stats["fts_rebuild_pending"] = (progress or 0) < high_water
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT value FROM state_meta WHERE key = ? LIMIT 1",
|
|
(FTS_REBUILD_DEFERRAL_KEY,),
|
|
).fetchone()
|
|
if row:
|
|
parsed = json.loads(row[0])
|
|
if isinstance(parsed, dict):
|
|
stats["fts_rebuild_deferral"] = parsed
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return stats
|
|
|
|
|
|
def count_db_holders(db_path: Path) -> Optional[int]:
|
|
"""Best-effort count of processes holding ``db_path`` open (Linux only).
|
|
|
|
Scans ``/proc/*/fd`` symlinks for the resolved database path. Returns
|
|
the number of distinct PIDs with the file open, or ``None`` on any
|
|
error or on non-Linux platforms. Never raises; no lsof dependency.
|
|
Unreadable per-process fd dirs (other users' processes without root)
|
|
are silently skipped, so the count is a lower bound.
|
|
"""
|
|
try:
|
|
if not sys.platform.startswith("linux"):
|
|
return None
|
|
target = os.path.realpath(str(db_path))
|
|
holders = 0
|
|
for pid in os.listdir("/proc"):
|
|
if not pid.isdigit():
|
|
continue
|
|
fd_dir = f"/proc/{pid}/fd"
|
|
try:
|
|
fds = os.listdir(fd_dir)
|
|
except OSError:
|
|
continue # process gone or not ours
|
|
for fd in fds:
|
|
try:
|
|
if os.readlink(f"{fd_dir}/{fd}") == target:
|
|
holders += 1
|
|
break # one hit per PID
|
|
except OSError:
|
|
continue
|
|
return holders
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _is_inactive_orphan_desktop_holder(
|
|
*,
|
|
ppid: int,
|
|
age_seconds: float,
|
|
min_age_seconds: float,
|
|
ephemeral_backend: bool,
|
|
connection_statuses: List[str],
|
|
) -> bool:
|
|
"""Pure safety predicate for the narrow Desktop holder reap."""
|
|
return (
|
|
ppid in (0, 1)
|
|
and age_seconds >= min_age_seconds
|
|
and ephemeral_backend
|
|
and "ESTABLISHED" not in connection_statuses
|
|
)
|
|
|
|
|
|
def _concrete_state_db_holder_pids(
|
|
db_path: Path, holders: List[Tuple[int, str]]
|
|
) -> List[int]:
|
|
"""Return unique PIDs proven to hold this DB or one of its sidecars."""
|
|
canonical_db = os.path.normcase(os.path.abspath(os.fspath(db_path)))
|
|
watched = {
|
|
canonical_db,
|
|
canonical_db + "-wal",
|
|
canonical_db + "-shm",
|
|
}
|
|
pids: List[int] = []
|
|
seen = set()
|
|
for pid, path in holders:
|
|
canonical_path = os.path.normcase(
|
|
os.path.abspath(path.removesuffix(" (deleted)"))
|
|
)
|
|
if pid <= 0 or pid in seen or canonical_path not in watched:
|
|
continue
|
|
seen.add(pid)
|
|
pids.append(pid)
|
|
return pids
|
|
|
|
|
|
def _read_proc_cmdline(pid: int) -> Optional[str]:
|
|
"""Read /proc/<pid>/cmdline, world-readable even when fd table is not.
|
|
|
|
Returns the cmdline as a space-joined string, or None when unreadable
|
|
(process exited, or hidepid mount).
|
|
"""
|
|
try:
|
|
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
|
raw = f.read()
|
|
if not raw:
|
|
return None
|
|
return raw.replace(b"\x00", b" ").decode("utf-8", "replace").strip()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
_HERMES_CMDLINE_MARKERS = ("hermes_cli.main", "hermes_cli/main", "hermes serve",
|
|
"hermes-agent", "hermes gateway", "hermes chat")
|
|
|
|
|
|
def _looks_like_hermes(cmdline: str) -> bool:
|
|
"""Heuristic: does this cmdline look like a Hermes process?
|
|
|
|
Used to decide whether an uninspectable process (fd table unreadable
|
|
due to different user) should be treated as a potential state.db holder.
|
|
We only flag processes that look like Hermes, not every system daemon.
|
|
"""
|
|
lower = cmdline.lower()
|
|
return any(marker in lower for marker in _HERMES_CMDLINE_MARKERS)
|
|
|
|
|
|
# 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
|
|
|
|
|
|
class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin):
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
# ── 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
|
|
# 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
|
|
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.
|
|
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
|
|
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():
|
|
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()
|
|
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] if not isinstance(row, sqlite3.Row) else row["sql"]) 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
|
|
|
|
# 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_replaced()
|
|
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:
|
|
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
|
|
# Non-lock error or patience exhausted — propagate.
|
|
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
|
|
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 _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)
|
|
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:
|
|
with self._read_ctx() as conn:
|
|
pragma_row = conn.execute("PRAGMA application_id").fetchone()
|
|
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 _raise_if_db_replaced(self) -> None:
|
|
if self._db_replaced:
|
|
raise StateDbReplacedError(_STATE_DB_REPLACED_MSG)
|
|
if self._db_file_was_replaced():
|
|
self._halt_db_replaced()
|
|
|
|
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")]
|
|
|
|
def _canonical(path: str) -> str:
|
|
clean = path.removesuffix(" (deleted)")
|
|
return os.path.normcase(os.path.abspath(clean))
|
|
|
|
db_path = os.path.abspath(os.fspath(self.db_path))
|
|
watched = {
|
|
_canonical(db_path),
|
|
_canonical(db_path + "-wal"),
|
|
_canonical(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(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(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 _reap_inactive_orphan_desktop_holders(
|
|
self, holders: List[Tuple[int, str]], *, min_age_seconds: float
|
|
) -> List[int]:
|
|
"""Terminate old PPID-1 Desktop ephemeral backends with no client.
|
|
|
|
Inspection fails closed: anything whose parent, age, argv, or network
|
|
connections cannot be proved safe remains a repair-blocking holder.
|
|
"""
|
|
if not sys.platform.startswith("linux") or psutil is None:
|
|
return []
|
|
try:
|
|
from hermes_cli.dashboard_procs import _is_ephemeral_port_zero_backend
|
|
except Exception:
|
|
return []
|
|
|
|
now = time.time()
|
|
candidates = []
|
|
for pid in _concrete_state_db_holder_pids(self.db_path, holders):
|
|
try:
|
|
process = psutil.Process(pid)
|
|
statuses = [
|
|
conn.status for conn in process.net_connections(kind="inet")
|
|
]
|
|
if not _is_inactive_orphan_desktop_holder(
|
|
ppid=process.ppid(),
|
|
age_seconds=now - process.create_time(),
|
|
min_age_seconds=min_age_seconds,
|
|
ephemeral_backend=_is_ephemeral_port_zero_backend(process.cmdline()),
|
|
connection_statuses=statuses,
|
|
):
|
|
continue
|
|
except Exception:
|
|
continue
|
|
candidates.append(process)
|
|
|
|
signalled: List[int] = []
|
|
for process in candidates:
|
|
try:
|
|
process.terminate()
|
|
signalled.append(process.pid)
|
|
except (psutil.Error, OSError):
|
|
continue
|
|
if not signalled:
|
|
return []
|
|
|
|
try:
|
|
_gone, alive = psutil.wait_procs(candidates, timeout=1.5)
|
|
except Exception:
|
|
alive = []
|
|
for process in alive:
|
|
try:
|
|
process.kill()
|
|
except (psutil.Error, OSError):
|
|
continue
|
|
if alive:
|
|
try:
|
|
psutil.wait_procs(alive, timeout=1.5)
|
|
except Exception:
|
|
pass
|
|
return signalled
|
|
|
|
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
|
|
if self._db_replaced or self._db_file_was_replaced():
|
|
self._halt_db_replaced()
|
|
|
|
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).
|
|
"""
|
|
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.
|
|
"""
|
|
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 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)
|
|
|
|
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's ``get_or_create_session`` creates a bare row (source +
|
|
user_id) *before* the agent exists; the agent's later
|
|
``create_session`` then carries the real ``model`` / ``model_config`` /
|
|
``system_prompt``. A plain ``INSERT OR IGNORE`` silently dropped that
|
|
enrichment, leaving gateway sessions with NULL model/billing metadata.
|
|
The ``ON CONFLICT`` upsert backfills those fields via ``COALESCE`` —
|
|
only filling columns that are still NULL, never overwriting values an
|
|
earlier writer already set (so a later bare call with source="unknown"
|
|
can't clobber a real source/model).
|
|
|
|
``chat_id``/``thread_id`` record the messaging origin (the chat/room and
|
|
thread the session was started in) so that gateway ``/resume`` can prove
|
|
a persisted, now-inactive row belongs to the caller's chat/thread before
|
|
switching to it (IDOR scoping — without them the ``sessions`` table has
|
|
no chat/thread to compare).
|
|
|
|
When ``parent_session_id`` is set (compression fork, delegate/subagent
|
|
spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/
|
|
``git_branch``/``profile_name`` are still NULL after the insert, they are
|
|
backfilled from the parent row. Callers of ``create_session`` for a child
|
|
session historically didn't propagate these fields themselves (e.g. the
|
|
compression-fork path), so a lineage could silently lose its working
|
|
directory and drop out of the project sidebar every time it forked
|
|
(#64709), or lose its owning profile and be aggregated as "default" every
|
|
time it rotated or branched (the cross-profile session-jump bug). This
|
|
only fills NULLs — an explicit value on the child is never overwritten.
|
|
For compression forks specifically
|
|
(parent ended with ``end_reason='compression'``), the gateway origin
|
|
columns (``user_id``/``session_key``/``chat_id``/``chat_type``/
|
|
``thread_id``/``display_name``/``origin_json``) are inherited too, so a
|
|
crash before the gateway re-records the peer can't strand the child
|
|
without a recoverable routing mapping (#59527).
|
|
|
|
When the caller passes no ``profile_name`` at all, the row is stamped
|
|
with THIS store's own profile (:meth:`_own_profile_name`) instead of
|
|
NULL. Every ``state.db`` belongs to exactly one profile — the same
|
|
single-match contract :meth:`backfill_null_session_profiles` relies
|
|
on — so the stamp is derivation, not a guess. Rows minted NULL after
|
|
that one-shot #94724 backfill ran stayed NULL forever, and
|
|
profile-keyed consumers (desktop sidebar scope matching,
|
|
``@session:<profile>/<id>`` deep links, the fail-closed owner ladder)
|
|
treat NULL as unowned: the session vanishes from the sidebar even
|
|
though its transcript is intact (#99222). Stores outside the profile
|
|
tree (explicit ``db_path`` in tests, ad-hoc copies) 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(
|
|
"""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))
|
|
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 record_gateway_session_peer(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
source: str,
|
|
user_id: str = None,
|
|
session_key: str = None,
|
|
chat_id: str = None,
|
|
chat_type: str = None,
|
|
thread_id: str = None,
|
|
display_name: str = None,
|
|
origin_json: str = None,
|
|
include_compression_ancestors: bool = False,
|
|
) -> None:
|
|
"""Persist the gateway routing peer for an existing session row.
|
|
|
|
``display_name`` / ``origin_json`` carry the gateway's presentation
|
|
and full origin metadata (#9006) so consumers (mcp_serve, mirror,
|
|
channel directory) can read routing data from state.db instead of
|
|
sessions.json. They are COALESCE'd only in the sense that ``None``
|
|
leaves the existing value untouched.
|
|
|
|
``include_compression_ancestors`` keeps a logical compression lineage
|
|
on one routing peer when an explicit gateway resume moves its tip to a
|
|
different lane. Normal per-turn metadata refreshes update only the
|
|
supplied row.
|
|
|
|
Self-healing (#82616): when the target row does not exist yet — the
|
|
gateway's ``create_session`` write failed and was deferred, or a
|
|
crash landed between routing publication and row creation — this
|
|
recorder INSERTs the row with the full identity instead of silently
|
|
no-opping. Every per-turn peer refresh is therefore a repair
|
|
opportunity: a gateway session row can no longer be first-created by
|
|
an identity-less lazy writer (``update_token_counts`` /
|
|
``record_auxiliary_usage``) and stay unroutable forever.
|
|
"""
|
|
if not session_id or not session_key:
|
|
return
|
|
|
|
def _do(conn):
|
|
lineage_cte = ""
|
|
target_clause = "WHERE id = ?"
|
|
query_params = []
|
|
if include_compression_ancestors:
|
|
lineage_cte = """
|
|
WITH RECURSIVE compression_lineage(id) AS (
|
|
SELECT ?
|
|
UNION
|
|
SELECT parent.id
|
|
FROM compression_lineage lineage
|
|
JOIN sessions child ON child.id = lineage.id
|
|
JOIN sessions parent ON parent.id = child.parent_session_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'
|
|
)
|
|
"""
|
|
target_clause = "WHERE id IN (SELECT id FROM compression_lineage)"
|
|
query_params.append(session_id)
|
|
query_params.extend(
|
|
(
|
|
session_key,
|
|
source,
|
|
user_id,
|
|
chat_id,
|
|
chat_type,
|
|
thread_id,
|
|
display_name,
|
|
origin_json,
|
|
)
|
|
)
|
|
if not include_compression_ancestors:
|
|
query_params.append(session_id)
|
|
conn.execute(
|
|
f"""{lineage_cte}
|
|
UPDATE sessions
|
|
SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
|
|
chat_type = ?, thread_id = ?,
|
|
display_name = COALESCE(?, display_name),
|
|
origin_json = COALESCE(?, origin_json)
|
|
{target_clause}""",
|
|
query_params,
|
|
)
|
|
# Self-heal (#82616): the UPDATE is a silent no-op when the row
|
|
# is missing (create_session failed earlier, or a crash landed
|
|
# between routing publication and row creation). Insert it with
|
|
# the full identity so the session is durably routable — never
|
|
# leave first-creation to an identity-less lazy writer.
|
|
if not include_compression_ancestors:
|
|
cur = conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
|
|
)
|
|
if cur.fetchone() is None:
|
|
conn.execute(
|
|
"""INSERT INTO sessions (
|
|
id, source, user_id, session_key, chat_id,
|
|
chat_type, thread_id, display_name, origin_json,
|
|
profile_name, started_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
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),
|
|
display_name = COALESCE(sessions.display_name, excluded.display_name),
|
|
origin_json = COALESCE(sessions.origin_json, excluded.origin_json)""",
|
|
(
|
|
session_id,
|
|
source,
|
|
user_id,
|
|
session_key,
|
|
chat_id,
|
|
chat_type,
|
|
thread_id,
|
|
display_name,
|
|
origin_json,
|
|
# Same ownership stamp as _insert_session_row: a
|
|
# self-healed row is a first creation too, and an
|
|
# unowned (NULL) row vanishes from profile-keyed
|
|
# consumers (#99222).
|
|
self._own_profile_name(),
|
|
time.time(),
|
|
),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
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
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET expiry_finalized = ? WHERE id = ?",
|
|
(1 if finalized else 0, session_id),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
# ── Gateway routing index (replaces sessions.json, #9006 follow-up) ────
|
|
|
|
def save_gateway_routing_entry(
|
|
self, session_key: str, entry_json: str, *, scope: str = ""
|
|
) -> None:
|
|
"""Upsert one gateway routing entry (session_key -> SessionEntry JSON).
|
|
|
|
The gateway_routing table is the durable replacement for
|
|
sessions.json: one row per routing key, holding the full serialized
|
|
``SessionEntry`` so the gateway can rehydrate exactly what it wrote.
|
|
|
|
``scope`` namespaces the index the way separate sessions.json files
|
|
did (one per sessions_dir) — callers pass their sessions_dir path so
|
|
two stores with different directories never share routing state.
|
|
"""
|
|
if not session_key or not entry_json:
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"""INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(scope, session_key) DO UPDATE SET
|
|
entry_json = excluded.entry_json,
|
|
updated_at = excluded.updated_at""",
|
|
(scope, session_key, entry_json, time.time()),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def replace_gateway_routing_entries(
|
|
self, entries: Dict[str, str], *, scope: str = ""
|
|
) -> None:
|
|
"""Atomically replace the routing index for *scope* with *entries*.
|
|
|
|
Mirrors the sessions.json full-rewrite semantics: keys absent from
|
|
*entries* are removed (pruned/reset sessions disappear from the
|
|
index). Runs as a single write transaction. Other scopes are
|
|
untouched.
|
|
"""
|
|
now = time.time()
|
|
|
|
def _do(conn):
|
|
conn.execute("DELETE FROM gateway_routing WHERE scope = ?", (scope,))
|
|
if entries:
|
|
conn.executemany(
|
|
"INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
[(scope, k, v, now) for k, v in entries.items() if k and v],
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def load_gateway_routing_entries(self, *, scope: str = "") -> Dict[str, str]:
|
|
"""Load routing entries for *scope* as {session_key: entry_json}."""
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"SELECT session_key, entry_json FROM gateway_routing WHERE scope = ?",
|
|
(scope,),
|
|
).fetchall()
|
|
return {r["session_key"]: r["entry_json"] for r in rows}
|
|
|
|
def delete_gateway_routing_entries(
|
|
self, session_keys: List[str], *, scope: str = ""
|
|
) -> None:
|
|
"""Remove routing entries for the given session keys in *scope*."""
|
|
if not session_keys:
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.executemany(
|
|
"DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?",
|
|
[(scope, k) for k in session_keys],
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def list_never_active_keyed_sessions(
|
|
self, *, older_than_days: float
|
|
) -> List[Dict[str, Any]]:
|
|
"""Keyed gateway rows that were opened and then never used at all.
|
|
|
|
Selects rows that are keyed (``session_key IS NOT NULL``), still open
|
|
(``ended_at IS NULL``) and carry no evidence of a single turn: no
|
|
messages, no tokens, no tool or API calls, no recorded activity, no
|
|
title. Such a row is indistinguishable from "never happened".
|
|
|
|
That is exactly the shape of a leaked test fixture (#82770) — and
|
|
also of a chat that was routed but never answered. Both are safe to
|
|
drop: there is no transcript to lose, and the gateway mints a fresh
|
|
session on the next inbound message either way.
|
|
|
|
``bulk prune``/``archive`` cannot reach these rows: their shared
|
|
selector is pinned to ``ended_at IS NOT NULL`` so that a live session
|
|
is never picked, which permanently excludes every never-closed row.
|
|
Hence a separate, narrower selector rather than another filter flag.
|
|
|
|
``pinned`` and ``archived`` rows are excluded — both are explicit
|
|
user intent to keep the row around.
|
|
"""
|
|
cutoff = time.time() - (float(older_than_days) * 86400.0)
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT s.id, s.session_key, s.source, s.chat_id,
|
|
s.chat_type, s.user_id, s.started_at
|
|
FROM sessions s
|
|
WHERE s.session_key IS NOT NULL
|
|
AND s.ended_at IS NULL
|
|
AND s.title IS NULL
|
|
AND s.last_activity_at IS NULL
|
|
AND COALESCE(s.message_count, 0) = 0
|
|
AND COALESCE(s.tool_call_count, 0) = 0
|
|
AND COALESCE(s.api_call_count, 0) = 0
|
|
AND COALESCE(s.input_tokens, 0) = 0
|
|
AND COALESCE(s.output_tokens, 0) = 0
|
|
AND COALESCE(s.pinned, 0) = 0
|
|
AND COALESCE(s.archived, 0) = 0
|
|
AND s.started_at IS NOT NULL
|
|
AND s.started_at < ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM messages m WHERE m.session_id = s.id
|
|
)
|
|
ORDER BY s.started_at
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def _delete_routing_entries_for_sessions(self, session_ids: Set[str]) -> int:
|
|
"""Drop ``gateway_routing`` rows pointing at any of *session_ids*.
|
|
|
|
Routing entries are keyed by ``(scope, session_key)`` and record their
|
|
target session inside ``entry_json``, so there is no way to reach them
|
|
by session id in SQL — the match is done in Python over all scopes.
|
|
"""
|
|
if not session_ids:
|
|
return 0
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"SELECT scope, session_key, entry_json FROM gateway_routing"
|
|
).fetchall()
|
|
doomed: List[Tuple[str, str]] = []
|
|
for row in rows:
|
|
try:
|
|
entry = json.loads(row["entry_json"] or "{}")
|
|
except Exception:
|
|
continue
|
|
if isinstance(entry, dict) and entry.get("session_id") in session_ids:
|
|
doomed.append((row["scope"], row["session_key"]))
|
|
if not doomed:
|
|
return 0
|
|
|
|
def _do(conn):
|
|
conn.executemany(
|
|
"DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?",
|
|
doomed,
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
return len(doomed)
|
|
|
|
def prune_never_active_keyed_sessions(
|
|
self,
|
|
*,
|
|
older_than_days: float,
|
|
sessions_dir: Optional[Path] = None,
|
|
) -> Tuple[int, int]:
|
|
"""Delete never-active keyed rows and the routing entries naming them.
|
|
|
|
Returns ``(sessions_deleted, routing_entries_deleted)``.
|
|
|
|
The routing entries go first: a stale entry that outlived its target
|
|
would leave the gateway resuming a session id that no longer exists.
|
|
Deleting the pair is what leaving them both would have amounted to
|
|
anyway — the target had no transcript to resume.
|
|
|
|
Deletion goes through :meth:`delete_session` rather than a bulk
|
|
``DELETE`` so the delegate cascade, FTS bookkeeping and on-disk
|
|
transcript cleanup stay owned by one implementation.
|
|
"""
|
|
candidates = self.list_never_active_keyed_sessions(
|
|
older_than_days=older_than_days
|
|
)
|
|
if not candidates:
|
|
return (0, 0)
|
|
ids = {str(row["id"]) for row in candidates}
|
|
routing_deleted = self._delete_routing_entries_for_sessions(ids)
|
|
deleted = 0
|
|
for session_id in ids:
|
|
if self.delete_session(session_id, sessions_dir=sessions_dir):
|
|
deleted += 1
|
|
return (deleted, routing_deleted)
|
|
|
|
def list_gateway_sessions(
|
|
self,
|
|
*,
|
|
platform: Optional[str] = None,
|
|
active_only: bool = True,
|
|
) -> List[Dict[str, Any]]:
|
|
"""List gateway sessions (rows with a session_key) from state.db.
|
|
|
|
Returns the newest row per session_key — the same shape consumers got
|
|
from sessions.json: one live mapping per routing key. ``platform``
|
|
filters on ``source``; ``active_only`` restricts to sessions that
|
|
have not ended.
|
|
"""
|
|
# Full rows carry token/cost totals (MCP listings, /status) — drain
|
|
# queued async accounting deltas so consumers see exact counters.
|
|
self.flush_token_counts()
|
|
query = f"""
|
|
SELECT sessions.*,
|
|
COALESCE(sp.prompt, sessions.system_prompt)
|
|
AS _system_prompt_resolved,
|
|
{_sql_session_last_active("sessions")} AS last_active
|
|
FROM sessions
|
|
LEFT JOIN system_prompts sp
|
|
ON sp.hash = sessions.system_prompt_hash
|
|
WHERE session_key IS NOT NULL
|
|
AND started_at = (
|
|
SELECT MAX(s2.started_at) FROM sessions s2
|
|
WHERE s2.session_key = sessions.session_key
|
|
)
|
|
"""
|
|
params: list = []
|
|
if platform:
|
|
query += " AND LOWER(source) = LOWER(?)"
|
|
params.append(platform)
|
|
if active_only:
|
|
query += " AND ended_at IS NULL"
|
|
query += " ORDER BY last_active DESC"
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(query, params).fetchall()
|
|
return [self._session_row_dict(r) for r in rows]
|
|
|
|
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"])
|
|
|
|
def find_latest_gateway_session_for_peer(
|
|
self,
|
|
*,
|
|
source: str,
|
|
user_id: Optional[str] = None,
|
|
session_key: Optional[str] = None,
|
|
chat_id: Optional[str] = None,
|
|
chat_type: Optional[str] = None,
|
|
thread_id: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Find the latest recoverable gateway session for a routing peer.
|
|
|
|
``sessions.json`` is the fast routing index, but it can be missing or
|
|
pruned after process-level restart bugs. New gateway sessions persist
|
|
the deterministic ``session_key`` on the durable session row so the
|
|
mapping can be rebuilt exactly. Rows ended only by older gateway
|
|
cleanup's ``agent_close`` bug or a mistaken TUI ``ws_orphan_reap``
|
|
(dashboard viewer disconnect before #60609) are treated as recoverable;
|
|
explicit conversation boundaries such as /new, /resume switches, and
|
|
compression splits are not.
|
|
|
|
Ordering and emptiness (#82616): candidates are ranked by actual
|
|
conversation recency (``last_activity_at``, falling back to
|
|
``started_at``) — ``started_at`` alone resurrected days-old zombie
|
|
rows over the live conversation. Rows with messages are preferred,
|
|
but an empty keyed row is still returned rather than ``None``:
|
|
returning ``None`` mints a brand-new session id, which is a worse
|
|
outcome than resuming an empty-but-correctly-keyed row (and "empty"
|
|
may just mean the transcript lives under a compression child).
|
|
|
|
Reset boundaries fence recovery (#68539): an intentional boundary
|
|
such as ``session_reset`` (or any explicit non-recoverable
|
|
end_reason) must block fallback to an *older* row for the same
|
|
peer. Without the fence, the has-messages ranking above could reach
|
|
behind a /new reset and silently restore the exact context the user
|
|
reset. Each candidate is therefore rejected when a boundary row for
|
|
the peer ended *after* the candidate's last activity — if the
|
|
conversation's most recent event is an intentional reset, recovery
|
|
returns nothing rather than reaching behind it.
|
|
"""
|
|
if not session_key:
|
|
return None
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT s.*,
|
|
COALESCE(sp.prompt, s.system_prompt)
|
|
AS _system_prompt_resolved,
|
|
(COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
|
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
|
)) AS _has_messages
|
|
FROM sessions s
|
|
LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
|
|
WHERE s.session_key = ?
|
|
AND s.source = ?
|
|
AND (s.ended_at IS NULL OR s.end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM sessions b
|
|
WHERE b.session_key = s.session_key
|
|
AND b.source = s.source
|
|
AND b.ended_at IS NOT NULL
|
|
AND b.end_reason IN ({_RESET_END_REASONS_SQL})
|
|
AND b.ended_at
|
|
> COALESCE(s.last_activity_at, s.started_at)
|
|
)
|
|
ORDER BY _has_messages DESC,
|
|
COALESCE(s.last_activity_at, s.started_at) DESC
|
|
LIMIT 1
|
|
""",
|
|
(session_key, source),
|
|
).fetchone()
|
|
if row is not None:
|
|
return self._session_row_dict(row)
|
|
|
|
# Conservative fallback for rows created by current code but with a
|
|
# temporarily-missing exact key: still require the complete peer
|
|
# tuple so we never cross chats/threads/users.
|
|
if chat_id is None or chat_type is None:
|
|
return None
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT s.*,
|
|
COALESCE(sp.prompt, s.system_prompt)
|
|
AS _system_prompt_resolved,
|
|
(COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
|
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
|
)) AS _has_messages
|
|
FROM sessions s
|
|
LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
|
|
WHERE s.source = ?
|
|
AND COALESCE(s.user_id, '') = COALESCE(?, '')
|
|
AND COALESCE(s.chat_id, '') = COALESCE(?, '')
|
|
AND COALESCE(s.chat_type, '') = COALESCE(?, '')
|
|
AND COALESCE(s.thread_id, '') = COALESCE(?, '')
|
|
AND (s.ended_at IS NULL OR s.end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))
|
|
AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
|
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
|
))
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM sessions b
|
|
WHERE b.source = s.source
|
|
AND COALESCE(b.user_id, '') = COALESCE(s.user_id, '')
|
|
AND COALESCE(b.chat_id, '') = COALESCE(s.chat_id, '')
|
|
AND COALESCE(b.chat_type, '') = COALESCE(s.chat_type, '')
|
|
AND COALESCE(b.thread_id, '') = COALESCE(s.thread_id, '')
|
|
AND b.ended_at IS NOT NULL
|
|
AND b.end_reason IN ({_RESET_END_REASONS_SQL})
|
|
AND b.ended_at
|
|
> COALESCE(s.last_activity_at, s.started_at)
|
|
)
|
|
ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC
|
|
LIMIT 1
|
|
""",
|
|
(source, user_id, chat_id, chat_type, thread_id),
|
|
).fetchone()
|
|
return self._session_row_dict(row) if row else None
|
|
|
|
# ── 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
|
|
|
|
def find_orphaned_gateway_sessions(
|
|
self, *, max_gap_s: Optional[float] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Report message-bearing session rows that lost their routing identity.
|
|
|
|
A row is a candidate orphan when it has messages but no
|
|
``session_key``. It is only *adoptable* when exactly one keyed
|
|
predecessor can be named as the conversation it continues:
|
|
|
|
* ``lineage`` — ``parent_session_id`` points at a keyed row of the
|
|
same source. That is a recorded fact, so no time window applies.
|
|
* ``contiguity`` — exactly one keyed row of the same source (and
|
|
compatible ``user_id``) fell quiet within *max_gap_s* of the
|
|
orphan's start, and is older than the orphan's own last activity.
|
|
|
|
Anything ambiguous is reported with ``adoptable=False`` and a reason
|
|
rather than guessed at: mis-adopting would splice one person's
|
|
conversation into another person's chat. Branch/delegate/tool rows
|
|
are excluded outright — they are unkeyed by design, not by damage.
|
|
"""
|
|
gap = (
|
|
self._ORPHAN_ADOPTION_MAX_GAP_S
|
|
if max_gap_s is None
|
|
else float(max_gap_s)
|
|
)
|
|
orphan_active = _sql_session_last_active("o")
|
|
donor_active = _sql_session_last_active("d")
|
|
donor_columns = (
|
|
"d.id, d.session_key, d.chat_id, d.chat_type, d.thread_id, "
|
|
"d.user_id, d.origin_json, d.display_name, d.end_reason"
|
|
)
|
|
records: List[Dict[str, Any]] = []
|
|
|
|
with self._read_ctx() as conn:
|
|
orphans = conn.execute(
|
|
f"""
|
|
SELECT o.id, o.source, o.user_id, o.started_at,
|
|
o.parent_session_id,
|
|
{orphan_active} AS last_active,
|
|
(SELECT COUNT(*) FROM messages m
|
|
WHERE m.session_id = o.id) AS message_count
|
|
FROM sessions o
|
|
WHERE o.session_key IS NULL
|
|
AND EXISTS (SELECT 1 FROM messages m
|
|
WHERE m.session_id = o.id)
|
|
AND COALESCE(o.source, '') != 'tool'
|
|
AND json_extract(COALESCE(o.model_config, '{{}}'),
|
|
'$._branched_from') IS NULL
|
|
AND json_extract(COALESCE(o.model_config, '{{}}'),
|
|
'$._delegate_from') IS NULL
|
|
ORDER BY o.started_at ASC
|
|
"""
|
|
).fetchall()
|
|
|
|
for orphan in orphans:
|
|
donor = None
|
|
evidence = ""
|
|
reason = ""
|
|
|
|
if orphan["parent_session_id"]:
|
|
evidence = "lineage"
|
|
donor = conn.execute(
|
|
f"""
|
|
SELECT {donor_columns}
|
|
FROM sessions d
|
|
WHERE d.id = ?
|
|
AND d.session_key IS NOT NULL
|
|
AND COALESCE(d.source, '') = COALESCE(?, '')
|
|
""",
|
|
(orphan["parent_session_id"], orphan["source"]),
|
|
).fetchone()
|
|
if donor is None:
|
|
reason = (
|
|
"parent session carries no gateway identity of "
|
|
"this source"
|
|
)
|
|
else:
|
|
evidence = "contiguity"
|
|
candidates = conn.execute(
|
|
f"""
|
|
SELECT {donor_columns}, {donor_active} AS last_active
|
|
FROM sessions d
|
|
WHERE d.session_key IS NOT NULL
|
|
AND d.id != ?
|
|
AND COALESCE(d.source, '') = COALESCE(?, '')
|
|
AND (COALESCE(d.user_id, '') = ''
|
|
OR COALESCE(?, '') = ''
|
|
OR d.user_id = ?)
|
|
AND {donor_active} BETWEEN ? AND ?
|
|
AND {donor_active} < ?
|
|
ORDER BY last_active DESC
|
|
LIMIT 2
|
|
""",
|
|
(
|
|
orphan["id"],
|
|
orphan["source"],
|
|
orphan["user_id"],
|
|
orphan["user_id"],
|
|
(orphan["started_at"] or 0) - gap,
|
|
(orphan["started_at"] or 0) + gap,
|
|
orphan["last_active"],
|
|
),
|
|
).fetchall()
|
|
if not candidates:
|
|
reason = (
|
|
f"no keyed predecessor fell quiet within {gap:.0f}s "
|
|
"of this session's start"
|
|
)
|
|
elif len(candidates) > 1:
|
|
reason = (
|
|
"ambiguous: more than one keyed predecessor "
|
|
"matches this window"
|
|
)
|
|
else:
|
|
donor = candidates[0]
|
|
|
|
records.append(
|
|
{
|
|
"orphan_id": orphan["id"],
|
|
"source": orphan["source"],
|
|
"message_count": orphan["message_count"],
|
|
"started_at": orphan["started_at"],
|
|
"last_active": orphan["last_active"],
|
|
"donor_id": donor["id"] if donor else None,
|
|
"session_key": donor["session_key"] if donor else None,
|
|
"evidence": evidence if donor else "",
|
|
"adoptable": donor is not None,
|
|
"reason": reason,
|
|
}
|
|
)
|
|
|
|
# Two unkeyed successors claiming the same predecessor means at most
|
|
# one of them continues that chat, and nothing here says which.
|
|
contested = {
|
|
r["donor_id"]
|
|
for r in records
|
|
if r["adoptable"]
|
|
and sum(1 for x in records if x["donor_id"] == r["donor_id"]) > 1
|
|
}
|
|
for record in records:
|
|
if record["donor_id"] in contested:
|
|
record["adoptable"] = False
|
|
record["reason"] = (
|
|
"ambiguous: more than one unkeyed session claims this "
|
|
"predecessor"
|
|
)
|
|
return records
|
|
|
|
def adopt_orphaned_gateway_session(
|
|
self, orphan_id: str, donor_id: str
|
|
) -> bool:
|
|
"""Stamp *orphan_id* with *donor_id*'s routing identity, retire *donor_id*.
|
|
|
|
Re-verifies the pair inside the write transaction, so a concurrent
|
|
gateway that healed either row in the meantime turns this into a
|
|
no-op instead of a conflicting write. Existing non-NULL columns on
|
|
the orphan are preserved. Returns True when the adoption applied.
|
|
"""
|
|
if not orphan_id or not donor_id or orphan_id == donor_id:
|
|
return False
|
|
|
|
def _do(conn):
|
|
donor = conn.execute(
|
|
"SELECT session_key, chat_id, chat_type, thread_id, user_id, "
|
|
"origin_json, display_name, source FROM sessions WHERE id = ?",
|
|
(donor_id,),
|
|
).fetchone()
|
|
orphan = conn.execute(
|
|
"SELECT session_key, source FROM sessions WHERE id = ?",
|
|
(orphan_id,),
|
|
).fetchone()
|
|
if donor is None or orphan is None:
|
|
return False
|
|
if not donor["session_key"] or orphan["session_key"]:
|
|
return False
|
|
if (donor["source"] or "") != (orphan["source"] or ""):
|
|
return False
|
|
|
|
conn.execute(
|
|
"""UPDATE sessions
|
|
SET session_key = ?,
|
|
chat_id = COALESCE(chat_id, ?),
|
|
chat_type = COALESCE(chat_type, ?),
|
|
thread_id = COALESCE(thread_id, ?),
|
|
user_id = COALESCE(user_id, ?),
|
|
origin_json = COALESCE(origin_json, ?),
|
|
display_name = COALESCE(display_name, ?),
|
|
parent_session_id = COALESCE(parent_session_id, ?)
|
|
WHERE id = ? AND session_key IS NULL""",
|
|
(
|
|
donor["session_key"],
|
|
donor["chat_id"],
|
|
donor["chat_type"],
|
|
donor["thread_id"],
|
|
donor["user_id"],
|
|
donor["origin_json"],
|
|
donor["display_name"],
|
|
donor_id,
|
|
orphan_id,
|
|
),
|
|
)
|
|
# Retire the predecessor under a reason recovery does NOT treat
|
|
# as resumable — 'agent_close'/'ws_orphan_reap' would keep it in
|
|
# the running, and the newly keyed orphan could lose the chat
|
|
# again on the next restart.
|
|
conn.execute(
|
|
"UPDATE sessions SET ended_at = COALESCE(ended_at, ?), "
|
|
"end_reason = 'superseded_by_repair' WHERE id = ?",
|
|
(time.time(), donor_id),
|
|
)
|
|
return True
|
|
|
|
return self._execute_write(_do)
|
|
|
|
# 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 find_live_compression_child(
|
|
self, parent_session_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Return the unique live direct child of a compression-ended session.
|
|
|
|
A stale agent may observe that another compression path already rotated
|
|
its parent. Recovery is safe only when the durable lineage identifies
|
|
exactly one live direct continuation. Multiple children are treated as
|
|
ambiguous and fail closed rather than guessing which transcript owns
|
|
subsequent messages.
|
|
"""
|
|
if not parent_session_id:
|
|
return None
|
|
with self._read_ctx() as conn:
|
|
parent = conn.execute(
|
|
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
|
|
(parent_session_id,),
|
|
).fetchone()
|
|
if (
|
|
parent is None
|
|
or parent["ended_at"] is None
|
|
or parent["end_reason"] != "compression"
|
|
):
|
|
return None
|
|
rows = conn.execute(
|
|
"""
|
|
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.parent_session_id = ?
|
|
AND s.ended_at IS NULL
|
|
"""
|
|
+ self._NON_CONTINUATION_CHILD_FILTER_SQL.format(alias="s.")
|
|
+ """
|
|
ORDER BY s.started_at ASC
|
|
LIMIT 2
|
|
""",
|
|
(parent_session_id, parent_session_id, parent_session_id),
|
|
).fetchall()
|
|
return self._session_row_dict(rows[0]) if len(rows) == 1 else None
|
|
|
|
def reopen_orphaned_compression_session(self, session_id: str) -> bool:
|
|
"""Reopen a compression parent only when no continuation was published.
|
|
|
|
Compression publication is atomic in current builds, but older builds
|
|
could leave a closed parent behind after an interrupted handoff. This
|
|
recovery is deliberately conservative: an active compression lease or
|
|
any canonical child means the lineage is still owned by another path,
|
|
so the caller must fail closed instead of reopening the parent.
|
|
"""
|
|
if not session_id:
|
|
return False
|
|
|
|
def _do(conn):
|
|
parent = conn.execute(
|
|
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if (
|
|
parent is None
|
|
or parent["ended_at"] is None
|
|
or parent["end_reason"] != "compression"
|
|
):
|
|
return False
|
|
|
|
# Treat any direct non-branch/non-delegate/non-tool child as a
|
|
# continuation, regardless of its current ended state. Reopening
|
|
# in that case could create a second live head for one lineage.
|
|
child = conn.execute(
|
|
"""
|
|
SELECT 1
|
|
FROM sessions
|
|
WHERE parent_session_id = ?
|
|
"""
|
|
+ self._NON_CONTINUATION_CHILD_FILTER_SQL.format(alias="")
|
|
+ """
|
|
LIMIT 1
|
|
""",
|
|
(session_id, session_id, session_id),
|
|
).fetchone()
|
|
if child is not None:
|
|
return False
|
|
|
|
# refresh_compression_lock() deliberately lets an owner revive its
|
|
# own expired row. Reclaim that row inside this write transaction
|
|
# before reopening: refresh-first makes the lease active and aborts
|
|
# recovery; recovery-first deletes the holder identity so a later
|
|
# refresh cannot resurrect it.
|
|
now = time.time()
|
|
lock_row = conn.execute(
|
|
"SELECT holder, expires_at FROM compression_locks "
|
|
"WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if lock_row is not None:
|
|
expires_at = lock_row["expires_at"]
|
|
if expires_at is None or float(expires_at) >= now:
|
|
return False
|
|
deleted = conn.execute(
|
|
"DELETE FROM compression_locks "
|
|
"WHERE session_id = ? AND holder = ? AND expires_at = ?",
|
|
(session_id, lock_row["holder"], expires_at),
|
|
)
|
|
if deleted.rowcount != 1:
|
|
return False
|
|
|
|
updated = conn.execute(
|
|
"UPDATE sessions SET ended_at = NULL, end_reason = NULL "
|
|
"WHERE id = ? AND ended_at IS NOT NULL "
|
|
"AND end_reason = 'compression'",
|
|
(session_id,),
|
|
)
|
|
# rowcount==1 is guaranteed by the parent SELECT at the top of
|
|
# this same BEGIN IMMEDIATE transaction. If this is ever edited
|
|
# to return False past this point, note that the lease DELETE
|
|
# above will still COMMIT (_execute_write commits unless _do
|
|
# raises) — raise instead of returning False to roll back.
|
|
return updated.rowcount == 1
|
|
|
|
return bool(self._execute_write(_do))
|
|
|
|
def publish_compression_child(
|
|
self,
|
|
*,
|
|
parent_session_id: str,
|
|
child_session_id: str,
|
|
source: str,
|
|
messages: List[Dict[str, Any]],
|
|
model: str = None,
|
|
model_config: Dict[str, Any] = None,
|
|
system_prompt: str = None,
|
|
cwd: str = None,
|
|
profile_name: str = None,
|
|
compression_lock_holder: str = None,
|
|
require_compression_lease: bool = True,
|
|
require_lease_refresh: bool = False,
|
|
lease_ttl_seconds: float = 300.0,
|
|
watermark: Optional[int] = None,
|
|
watermark_ceiling: Optional[int] = None,
|
|
) -> None:
|
|
"""Atomically close a parent and publish its durable compression child.
|
|
|
|
The parent closure, child row, and compacted handoff become visible in
|
|
one transaction. Readers can therefore observe either the live parent or
|
|
a complete child, never an ended parent with a missing/empty child.
|
|
|
|
Concurrent-append safety (#75316): when *watermark* is provided (the
|
|
parent's :meth:`get_active_message_watermark` captured at compression
|
|
start), parent rows that arrived during the slow summary call
|
|
(``id > watermark``) are cloned into the child AFTER the handoff —
|
|
same pure-SQL column clone as :meth:`archive_and_compact`, with the
|
|
session id rewritten — so a mid-compression append survives rotation
|
|
instead of stranding in the closed parent.
|
|
|
|
*watermark_ceiling* bounds the clone from above: the rotation path
|
|
flushes its OWN un-persisted input transcript to the parent right
|
|
before publishing (#47202), and those rows are already represented in
|
|
the compacted handoff — cloning them would duplicate the transcript.
|
|
The caller captures ``MAX(id)`` immediately BEFORE that flush; only
|
|
rows in ``(watermark, watermark_ceiling]`` are foreign concurrent
|
|
tail. ``None`` = unbounded (no internal flush happened).
|
|
|
|
When *require_lease_refresh* is True and *compression_lock_holder* is
|
|
set, the lease is refreshed inside the same transaction before the
|
|
expiry check. This gives a refresher
|
|
that stopped due to transient DB failures one final chance to extend
|
|
the lease, preventing wasted compression work. The refresh uses the
|
|
same ``conn`` as the publication, so there is no TOCTOU window.
|
|
"""
|
|
def _do(conn):
|
|
if require_lease_refresh and compression_lock_holder:
|
|
conn.execute(
|
|
"UPDATE compression_locks SET expires_at = ? "
|
|
"WHERE session_id = ? AND holder = ?",
|
|
(time.time() + lease_ttl_seconds, parent_session_id,
|
|
compression_lock_holder),
|
|
)
|
|
lock_row = conn.execute(
|
|
"SELECT holder, expires_at FROM compression_locks WHERE session_id = ?",
|
|
(parent_session_id,),
|
|
).fetchone()
|
|
if require_compression_lease and (
|
|
lock_row is None
|
|
or not compression_lock_holder
|
|
or lock_row["holder"] != compression_lock_holder
|
|
or float(lock_row["expires_at"]) <= time.time()
|
|
):
|
|
raise CompressionSessionBusyError(
|
|
f"Compression lease lost before publication: {parent_session_id}"
|
|
)
|
|
parent = conn.execute(
|
|
"""SELECT ended_at, end_reason, cwd, git_branch, git_repo_root,
|
|
user_id, session_key, chat_id, chat_type,
|
|
thread_id, display_name, origin_json, profile_name
|
|
FROM sessions WHERE id = ?""",
|
|
(parent_session_id,),
|
|
).fetchone()
|
|
if parent is None:
|
|
raise RuntimeError(f"Compression parent not found: {parent_session_id}")
|
|
if parent["ended_at"] is not None:
|
|
# A parent stamped ended by AUTOMATIC cleanup (tui_shutdown,
|
|
# ws_disconnect, orphan reap, idle/LRU evict) while a live
|
|
# agent is publishing its rotation is stale by construction —
|
|
# this writer holds the compression lease and is actively
|
|
# continuing the conversation the stamp claims is over.
|
|
# Left in place it wedges rotation forever: every attempt
|
|
# aborts here, nothing clears the stamp, and each attempt's
|
|
# pre-publish flush re-grows the parent until the provider
|
|
# rejects the request (#88197: 303 unique messages → 2,611
|
|
# rows → HTTP 400). Clear it in this same transaction and
|
|
# proceed; the closure UPDATE below re-stamps the parent with
|
|
# its true boundary (end_reason='compression'). Deliberate
|
|
# boundaries (compression, session_reset, explicit close)
|
|
# still fail closed — those mean another path owns lineage.
|
|
if is_automatic_end_reason(parent["end_reason"]):
|
|
conn.execute(
|
|
"UPDATE sessions SET ended_at = NULL, end_reason = NULL "
|
|
"WHERE id = ?",
|
|
(parent_session_id,),
|
|
)
|
|
else:
|
|
raise RuntimeError(
|
|
f"Compression parent already ended: {parent_session_id}"
|
|
)
|
|
if not messages:
|
|
raise RuntimeError("Compression child handoff must not be empty")
|
|
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
|
|
|
|
conn.execute(
|
|
"""INSERT INTO sessions (
|
|
id, source, model, model_config, system_prompt,
|
|
system_prompt_hash,
|
|
parent_session_id, cwd, git_branch, git_repo_root,
|
|
profile_name, user_id, session_key, chat_id, chat_type,
|
|
thread_id, display_name, origin_json, started_at
|
|
) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
child_session_id,
|
|
source,
|
|
model,
|
|
json.dumps(model_config) if model_config else None,
|
|
system_prompt_hash,
|
|
parent_session_id,
|
|
cwd or parent["cwd"],
|
|
parent["git_branch"],
|
|
parent["git_repo_root"],
|
|
# Same inheritance contract as _insert_session_row's
|
|
# compression-fork backfill (#59527 / cross-profile jump
|
|
# fix): the child stays on the parent's profile and keeps
|
|
# the gateway routing/origin columns so peer recovery
|
|
# still works after a crash at the boundary. When neither
|
|
# names an owner (legacy NULL parent), stamp this store's
|
|
# own profile so the rotated child doesn't extend the
|
|
# unowned lineage (#99222).
|
|
profile_name
|
|
or parent["profile_name"]
|
|
or self._own_profile_name(),
|
|
parent["user_id"],
|
|
parent["session_key"],
|
|
parent["chat_id"],
|
|
parent["chat_type"],
|
|
parent["thread_id"],
|
|
parent["display_name"],
|
|
parent["origin_json"],
|
|
time.time(),
|
|
),
|
|
)
|
|
total_messages, total_tool_calls = self._insert_message_rows(
|
|
conn, child_session_id, messages
|
|
)
|
|
if watermark is not None:
|
|
# Clone the parent's concurrent tail (rows landed after the
|
|
# watermark, at or below the ceiling — see docstring) into the
|
|
# child, after the handoff. Column-exact except id/session_id;
|
|
# originals stay in the (closed) parent for lineage recovery.
|
|
_ceiling_clause = ""
|
|
_params: list = [parent_session_id, int(watermark)]
|
|
if watermark_ceiling is not None:
|
|
_ceiling_clause = " AND id <= ?"
|
|
_params.append(int(watermark_ceiling))
|
|
tail_rows = conn.execute(
|
|
"SELECT id, tool_calls FROM messages "
|
|
"WHERE session_id = ? AND active = 1 AND id > ?"
|
|
f"{_ceiling_clause} ORDER BY id",
|
|
_params,
|
|
).fetchall()
|
|
if tail_rows:
|
|
tail_ids = [int(r["id"]) for r in tail_rows]
|
|
placeholders = ",".join("?" for _ in tail_ids)
|
|
clone_cols = [
|
|
c for c in self._message_column_names(conn)
|
|
if c not in ("id", "session_id", "active", "compacted")
|
|
]
|
|
col_list = ", ".join(clone_cols)
|
|
conn.execute(
|
|
f"INSERT INTO messages ({col_list}, session_id, active, compacted) "
|
|
f"SELECT {col_list}, ?, 1, 0 FROM messages "
|
|
f"WHERE id IN ({placeholders}) ORDER BY id",
|
|
[child_session_id, *tail_ids],
|
|
)
|
|
total_messages += len(tail_ids)
|
|
for r in tail_rows:
|
|
raw = r["tool_calls"]
|
|
if raw:
|
|
try:
|
|
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
|
total_tool_calls += len(parsed) if isinstance(parsed, list) else 0
|
|
except (TypeError, ValueError):
|
|
pass
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
|
|
(total_messages, total_tool_calls, child_session_id),
|
|
)
|
|
updated = conn.execute(
|
|
"UPDATE sessions SET ended_at = ?, end_reason = 'compression' "
|
|
"WHERE id = ? AND ended_at IS NULL",
|
|
(time.time(), parent_session_id),
|
|
)
|
|
if updated.rowcount != 1:
|
|
raise RuntimeError(
|
|
f"Compression parent changed during publication: {parent_session_id}"
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
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):
|
|
conn.execute(
|
|
"UPDATE sessions SET ended_at = ?, end_reason = ? "
|
|
"WHERE id = ? AND ended_at IS NULL",
|
|
(time.time(), end_reason, session_id),
|
|
)
|
|
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),
|
|
)
|
|
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["cwd"] if isinstance(current, sqlite3.Row) else 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["git_metadata_generation"] if isinstance(row, sqlite3.Row) else 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))
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
f"UPDATE sessions SET {', '.join(sets)} "
|
|
"WHERE id = ? AND cwd = ? "
|
|
"AND git_metadata_generation = ?",
|
|
params,
|
|
)
|
|
return cursor.rowcount == 1
|
|
|
|
return bool(self._execute_write(_do))
|
|
|
|
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)
|
|
|
|
def record_compression_failure_cooldown(
|
|
self,
|
|
session_id: str,
|
|
cooldown_until: float,
|
|
error: Optional[str] = None,
|
|
) -> None:
|
|
"""Persist the active compression-failure cooldown for a session."""
|
|
if not session_id:
|
|
return
|
|
|
|
def _do(conn):
|
|
# Merge-max with any longer live deadline so a later shorter
|
|
# write cannot reopen the thrash window (#96775). The error
|
|
# column always takes the latest diagnostic.
|
|
conn.execute(
|
|
"UPDATE sessions SET compression_failure_cooldown_until = CASE "
|
|
"WHEN compression_failure_cooldown_until IS NOT NULL "
|
|
" AND compression_failure_cooldown_until > ? "
|
|
"THEN compression_failure_cooldown_until ELSE ? END, "
|
|
"compression_failure_error = ? WHERE id = ?",
|
|
(cooldown_until, cooldown_until, error, session_id),
|
|
)
|
|
|
|
try:
|
|
self._execute_write(_do)
|
|
except sqlite3.Error as exc:
|
|
logger.warning(
|
|
"record_compression_failure_cooldown(%s) failed: %s",
|
|
session_id, exc,
|
|
)
|
|
|
|
def get_compression_failure_cooldown(
|
|
self,
|
|
session_id: str,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Return the active compression-failure cooldown for ``session_id``."""
|
|
if not session_id:
|
|
return None
|
|
now = time.time()
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT compression_failure_cooldown_until, compression_failure_error "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
cooldown_until = (
|
|
row["compression_failure_cooldown_until"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[0]
|
|
)
|
|
if cooldown_until is None:
|
|
return None
|
|
cooldown_until = float(cooldown_until)
|
|
if cooldown_until <= now:
|
|
return None
|
|
error = (
|
|
row["compression_failure_error"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[1]
|
|
)
|
|
return {
|
|
"cooldown_until": cooldown_until,
|
|
"remaining_seconds": cooldown_until - now,
|
|
"error": error,
|
|
}
|
|
|
|
def get_compression_failure_cooldown_row(
|
|
self,
|
|
session_id: str,
|
|
) -> Dict[str, Any]:
|
|
"""Return the exact stored cooldown columns without expiry filtering.
|
|
|
|
Compression cancellation uses this under its session lease so rollback
|
|
can preserve an expired row, a partially-null row, or an absent session
|
|
exactly instead of converting those states through the active-cooldown
|
|
API.
|
|
"""
|
|
if not session_id:
|
|
return {"session_exists": False, "cooldown_until": None, "error": None}
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT compression_failure_cooldown_until, compression_failure_error "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return {"session_exists": False, "cooldown_until": None, "error": None}
|
|
cooldown_until = (
|
|
row["compression_failure_cooldown_until"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[0]
|
|
)
|
|
error = (
|
|
row["compression_failure_error"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[1]
|
|
)
|
|
return {
|
|
"session_exists": True,
|
|
"cooldown_until": (
|
|
float(cooldown_until) if cooldown_until is not None else None
|
|
),
|
|
"error": error,
|
|
}
|
|
|
|
def restore_compression_failure_cooldown_row(
|
|
self,
|
|
session_id: str,
|
|
snapshot: Dict[str, Any],
|
|
) -> None:
|
|
"""Restore and verify an exact cooldown-row snapshot.
|
|
|
|
Unlike the ordinary record/clear helpers, this transactional rollback
|
|
API deliberately propagates write and verification failures. A caller
|
|
must not report cancellation as mutation-free when compensation failed.
|
|
"""
|
|
expected_exists = bool(snapshot.get("session_exists", False))
|
|
if not expected_exists:
|
|
actual = self.get_compression_failure_cooldown_row(session_id)
|
|
if actual.get("session_exists", False):
|
|
raise RuntimeError(
|
|
"cannot restore absent compression cooldown row: session now exists"
|
|
)
|
|
return
|
|
|
|
deadline = snapshot.get("cooldown_until")
|
|
error = snapshot.get("error")
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"UPDATE sessions SET compression_failure_cooldown_until = ?, "
|
|
"compression_failure_error = ? WHERE id = ?",
|
|
(deadline, error, session_id),
|
|
)
|
|
if cursor.rowcount != 1:
|
|
raise RuntimeError(
|
|
f"compression cooldown rollback session missing: {session_id}"
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
actual = self.get_compression_failure_cooldown_row(session_id)
|
|
expected = {
|
|
"session_exists": True,
|
|
"cooldown_until": float(deadline) if deadline is not None else None,
|
|
"error": error,
|
|
}
|
|
if actual != expected:
|
|
raise RuntimeError(
|
|
f"compression cooldown rollback verification failed: "
|
|
f"expected={expected!r}, actual={actual!r}"
|
|
)
|
|
|
|
def clear_compression_failure_cooldown(self, session_id: str) -> None:
|
|
"""Clear any persisted compression-failure cooldown for a session."""
|
|
if not session_id:
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET compression_failure_cooldown_until = NULL, "
|
|
"compression_failure_error = NULL WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
|
|
try:
|
|
self._execute_write(_do)
|
|
except sqlite3.Error as exc:
|
|
logger.warning(
|
|
"clear_compression_failure_cooldown(%s) failed: %s",
|
|
session_id, exc,
|
|
)
|
|
|
|
def get_compression_fallback_streak(self, session_id: str) -> int:
|
|
"""Return the persisted deterministic-fallback streak."""
|
|
if not session_id:
|
|
return 0
|
|
with self._read_ctx() as conn:
|
|
if conn is None:
|
|
return 0
|
|
row = conn.execute(
|
|
"SELECT compression_fallback_streak FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return 0
|
|
value = (
|
|
row["compression_fallback_streak"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[0]
|
|
)
|
|
try:
|
|
return max(0, int(value or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
def set_compression_fallback_streak(self, session_id: str, streak: int) -> None:
|
|
"""Persist the deterministic-fallback streak for one session."""
|
|
if not session_id:
|
|
return
|
|
normalized = max(0, int(streak))
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?",
|
|
(normalized, session_id),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def increment_hygiene_failure_streak(self, session_key: str) -> int:
|
|
"""Atomically increment the session-hygiene failure streak for one chat."""
|
|
if not session_key:
|
|
return 1
|
|
result = []
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"""INSERT INTO gateway_hygiene_state (session_key, failure_streak)
|
|
VALUES (?, 1)
|
|
ON CONFLICT(session_key) DO UPDATE SET
|
|
failure_streak = gateway_hygiene_state.failure_streak + 1""",
|
|
(session_key,),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT failure_streak FROM gateway_hygiene_state WHERE session_key = ?",
|
|
(session_key,),
|
|
).fetchone()
|
|
result.append(int(row[0]))
|
|
|
|
self._execute_write(_do)
|
|
return result[0]
|
|
|
|
def reset_hygiene_failure_streak(self, session_key: str) -> None:
|
|
"""Clear the persisted session-hygiene failure streak for one chat."""
|
|
if not session_key:
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"DELETE FROM gateway_hygiene_state WHERE session_key = ?",
|
|
(session_key,),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def get_compression_ineffective_count(self, session_id: str) -> int:
|
|
"""Return the persisted ineffective-compaction strike count.
|
|
|
|
Mirrors ``get_compression_fallback_streak``: this is the durable half
|
|
of the anti-thrash guard (``_ineffective_compression_count`` on the
|
|
built-in compressor), persisted so that a fresh compressor bound to a
|
|
resumed session inherits an armed/tripped guard instead of starting
|
|
from zero across process restarts (#54923).
|
|
"""
|
|
if not session_id:
|
|
return 0
|
|
with self._read_ctx() as conn:
|
|
if conn is None:
|
|
return 0
|
|
row = conn.execute(
|
|
"SELECT compression_ineffective_count FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return 0
|
|
value = (
|
|
row["compression_ineffective_count"]
|
|
if isinstance(row, sqlite3.Row)
|
|
else row[0]
|
|
)
|
|
try:
|
|
return max(0, int(value or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
def set_compression_ineffective_count(self, session_id: str, count: int) -> None:
|
|
"""Persist the ineffective-compaction strike count for one session."""
|
|
if not session_id:
|
|
return
|
|
normalized = max(0, int(count))
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET compression_ineffective_count = ? WHERE id = ?",
|
|
(normalized, session_id),
|
|
)
|
|
|
|
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 refresh_compression_lock(
|
|
self,
|
|
session_id: str,
|
|
holder: str,
|
|
ttl_seconds: float = 300.0,
|
|
) -> bool:
|
|
"""Extend the compression lock lease if ``holder`` still owns it.
|
|
|
|
Ownership is decided by the ``holder`` column alone, deliberately NOT
|
|
by ``expires_at``: a live owner whose refresher thread was starved
|
|
(GC pause, loaded CI runner, a slow write escaping ``_execute_write``'s
|
|
retry budget) past its own TTL must be able to revive its still-unclaimed
|
|
row on the next tick. Requiring ``expires_at >= now`` here made such a
|
|
stall permanent — every later refresh matched 0 rows, so the owner kept
|
|
compressing and rotating with no lease at all, which is exactly the
|
|
unprotected window a competing path can fork the session lineage in.
|
|
|
|
This does not resurrect a lock somebody else already took: SQLite
|
|
serialises writes, so a reclaim (DELETE-expired + INSERT-or-IGNORE in
|
|
:meth:`try_acquire_compression_lock`) and this UPDATE never interleave.
|
|
Reclaim-first replaces ``holder``, so this UPDATE matches nothing and
|
|
returns False; refresh-first pushes ``expires_at`` into the future, so
|
|
the reclaimer's DELETE-expired matches nothing and its acquire fails.
|
|
"""
|
|
if not session_id or not holder:
|
|
return False
|
|
now = time.time()
|
|
expires_at = now + ttl_seconds
|
|
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"UPDATE compression_locks SET expires_at = ? "
|
|
"WHERE session_id = ? AND holder = ?",
|
|
(expires_at, session_id, holder),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
try:
|
|
return bool(self._execute_write(_do))
|
|
except sqlite3.Error as exc:
|
|
logger.warning(
|
|
"refresh_compression_lock(%s) failed: %s",
|
|
session_id, exc,
|
|
)
|
|
return False
|
|
|
|
def try_acquire_compression_lock(
|
|
self,
|
|
session_id: str,
|
|
holder: str,
|
|
ttl_seconds: float = 300.0,
|
|
) -> bool:
|
|
"""Try to atomically acquire the compression lock for ``session_id``.
|
|
|
|
Returns ``True`` on success (caller now owns the lock and must
|
|
release via :meth:`release_compression_lock`). Returns ``False``
|
|
if another holder already owns a non-expired lock — the caller
|
|
MUST NOT proceed with compression in that case (its rotation would
|
|
race against the holder's, splitting the session lineage).
|
|
|
|
Expired locks (``expires_at < now``) are reclaimed transparently.
|
|
Structured holders whose local ``pid=`` no longer exists are reclaimed
|
|
immediately, so a gateway killed during compression does not stall the
|
|
replacement process for the full lease TTL.
|
|
|
|
Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE,
|
|
followed by a SELECT to confirm we got the row. SQLite serialises
|
|
writes, so the whole sequence is atomic against other writers.
|
|
"""
|
|
if not session_id:
|
|
return False
|
|
now = time.time()
|
|
expires_at = now + ttl_seconds
|
|
|
|
def _do(conn):
|
|
reclaimed_holder = None
|
|
row = conn.execute(
|
|
"SELECT holder, expires_at FROM compression_locks "
|
|
"WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is not None:
|
|
current_holder = (
|
|
row["holder"] if isinstance(row, sqlite3.Row) else row[0]
|
|
)
|
|
current_expires_at = (
|
|
row["expires_at"] if isinstance(row, sqlite3.Row) else row[1]
|
|
)
|
|
if (
|
|
current_expires_at < now
|
|
or _compression_lock_holder_process_is_dead(current_holder)
|
|
):
|
|
conn.execute(
|
|
"DELETE FROM compression_locks "
|
|
"WHERE session_id = ? AND holder = ?",
|
|
(session_id, current_holder),
|
|
)
|
|
reclaimed_holder = current_holder
|
|
# Then: try to insert. INSERT OR IGNORE returns no rowcount
|
|
# difference — verify ownership via SELECT.
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO compression_locks "
|
|
"(session_id, holder, acquired_at, expires_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(session_id, holder, now, expires_at),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT holder FROM compression_locks WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
acquired = row is not None and (
|
|
row["holder"] if isinstance(row, sqlite3.Row) else row[0]
|
|
) == holder
|
|
return acquired, reclaimed_holder
|
|
|
|
try:
|
|
acquired, reclaimed_holder = self._execute_write(_do)
|
|
if reclaimed_holder:
|
|
logger.warning(
|
|
"Reclaimed stale compression lock for session=%s "
|
|
"(holder=%s)",
|
|
session_id,
|
|
reclaimed_holder,
|
|
)
|
|
return bool(acquired)
|
|
except sqlite3.Error as exc:
|
|
logger.warning(
|
|
"try_acquire_compression_lock(%s) failed: %s",
|
|
session_id, exc,
|
|
)
|
|
# Fail open: returning False makes the caller skip compression,
|
|
# which is the safe behaviour when the lock subsystem is broken.
|
|
return False
|
|
|
|
def release_compression_lock(self, session_id: str, holder: str) -> None:
|
|
"""Release the compression lock for ``session_id`` iff we own it.
|
|
|
|
Idempotent: no-op when the lock has already expired and been
|
|
reclaimed by a different holder, or when no lock exists. The
|
|
``holder`` check prevents a late-returning compressor from
|
|
clobbering a fresh lock held by someone else.
|
|
"""
|
|
if not session_id:
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"DELETE FROM compression_locks "
|
|
"WHERE session_id = ? AND holder = ?",
|
|
(session_id, holder),
|
|
)
|
|
|
|
try:
|
|
self._execute_write(_do)
|
|
except sqlite3.Error as exc:
|
|
logger.warning(
|
|
"release_compression_lock(%s) failed: %s",
|
|
session_id, exc,
|
|
)
|
|
|
|
def _session_turn_lease_key_on_conn(self, conn, session_id: str) -> str:
|
|
"""Walk compression parents on ``conn`` to the conversation lease key.
|
|
|
|
Must run on the same connection as the lease INSERT/UPDATE/DELETE.
|
|
A prior ``get_session`` failure must not compute a child id that the
|
|
later write then persists: refresh would walk to the parent and
|
|
fail-close. Markers bind to ``parent_session_id`` (same contract as
|
|
``_NON_CONTINUATION_CHILD_FILTER_SQL``). Lock errors propagate so
|
|
``_execute_write`` / ``acquire_session_turn_lease`` can retry.
|
|
"""
|
|
if not session_id:
|
|
return session_id
|
|
|
|
def _row(sid: str):
|
|
row = conn.execute(
|
|
"SELECT id, parent_session_id, source, model_config, end_reason "
|
|
"FROM sessions WHERE id = ?",
|
|
(sid,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
current = _row(session_id)
|
|
seen = {session_id}
|
|
while current:
|
|
parent_id = current.get("parent_session_id")
|
|
if (
|
|
not parent_id
|
|
or parent_id in seen
|
|
or self._is_explicit_fork_child_row(current)
|
|
):
|
|
break
|
|
parent = _row(parent_id)
|
|
if not parent or parent.get("end_reason") != "compression":
|
|
break
|
|
seen.add(parent_id)
|
|
current = parent
|
|
return str(current.get("id") or session_id) if current else session_id
|
|
|
|
def _session_turn_lease_key(self, session_id: str) -> str:
|
|
"""Return the stable serialization key for every compression segment.
|
|
|
|
Acquire/refresh/release resolve this inside their write transaction.
|
|
This helper is for tests and diagnostics; it does not swallow lock
|
|
errors (a swallowed walk plus a later successful write was the
|
|
fail-open that replayed the post-rotation refresh miss).
|
|
"""
|
|
if not session_id:
|
|
return session_id
|
|
with self._read_ctx() as conn:
|
|
return self._session_turn_lease_key_on_conn(conn, session_id)
|
|
|
|
def try_acquire_session_turn_lease(
|
|
self,
|
|
session_id: str,
|
|
holder: str,
|
|
*,
|
|
ttl_seconds: float = 300.0,
|
|
patience_s: Optional[float] = None,
|
|
) -> bool:
|
|
"""Atomically acquire the cross-process turn lease for a conversation.
|
|
|
|
Compression rotates a session into child segments, so the durable key
|
|
is the lineage root rather than the current segment id. The walk and
|
|
INSERT share one write transaction. Expired leases and leases whose
|
|
structured local holder PID is known dead are reclaimed in that same
|
|
transaction.
|
|
"""
|
|
if not session_id or not holder:
|
|
return False
|
|
now = time.time()
|
|
expires_at = now + max(0.1, float(ttl_seconds))
|
|
|
|
def _do(conn):
|
|
conversation_id = self._session_turn_lease_key_on_conn(conn, session_id)
|
|
row = conn.execute(
|
|
"SELECT holder, expires_at FROM session_turn_leases "
|
|
"WHERE conversation_id = ?",
|
|
(conversation_id,),
|
|
).fetchone()
|
|
if row is not None:
|
|
current_holder = row["holder"]
|
|
if (
|
|
float(row["expires_at"]) <= now
|
|
or _compression_lock_holder_process_is_dead(current_holder)
|
|
):
|
|
conn.execute(
|
|
"DELETE FROM session_turn_leases "
|
|
"WHERE conversation_id = ? AND holder = ?",
|
|
(conversation_id, current_holder),
|
|
)
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO session_turn_leases "
|
|
"(conversation_id, holder, acquired_at, expires_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(conversation_id, holder, now, expires_at),
|
|
)
|
|
owner = conn.execute(
|
|
"SELECT holder FROM session_turn_leases WHERE conversation_id = ?",
|
|
(conversation_id,),
|
|
).fetchone()
|
|
return owner is not None and owner["holder"] == holder
|
|
|
|
return bool(self._execute_write(_do, patience_s=patience_s))
|
|
|
|
def acquire_session_turn_lease(
|
|
self,
|
|
session_id: str,
|
|
holder: str,
|
|
*,
|
|
ttl_seconds: float = 300.0,
|
|
wait_seconds: float = 1800.0,
|
|
poll_interval_seconds: float = 1.0,
|
|
on_wait=None,
|
|
wait_notice_interval_seconds: float = 15.0,
|
|
should_abort=None,
|
|
acquire_patience_s: float = 0.5,
|
|
) -> bool:
|
|
"""Wait for a cross-process turn lease without holding a SQLite lock.
|
|
|
|
``on_wait(elapsed_seconds)`` is best-effort: invoked when the first
|
|
attempt fails (elapsed ~0) and again about every
|
|
``wait_notice_interval_seconds`` while still waiting, so UIs can show
|
|
that another process holds the conversation.
|
|
|
|
When ``should_abort()`` returns True (for example the agent received
|
|
``/stop`` while waiting), acquisition stops immediately and returns
|
|
False without consuming the full ``wait_seconds`` budget.
|
|
"""
|
|
deadline = time.monotonic() + max(0.0, float(wait_seconds))
|
|
wait_started = None
|
|
last_notice_at = None
|
|
notice_every = max(0.0, float(wait_notice_interval_seconds))
|
|
while True:
|
|
if should_abort is not None:
|
|
try:
|
|
if should_abort():
|
|
return False
|
|
except Exception:
|
|
logger.debug(
|
|
"session turn lease should_abort callback failed",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
if self.try_acquire_session_turn_lease(
|
|
session_id,
|
|
holder,
|
|
ttl_seconds=ttl_seconds,
|
|
patience_s=acquire_patience_s,
|
|
):
|
|
return True
|
|
except sqlite3.Error as exc:
|
|
# Long holder transactions (compression publish, large
|
|
# flushes) can exhaust a single write-patience budget.
|
|
# Keep polling until wait_seconds or should_abort.
|
|
if classify_persistence_error(exc) != "locked":
|
|
raise
|
|
now = time.monotonic()
|
|
remaining = deadline - now
|
|
if remaining <= 0:
|
|
return False
|
|
if wait_started is None:
|
|
wait_started = now
|
|
if on_wait is not None and (
|
|
last_notice_at is None
|
|
or notice_every == 0.0
|
|
or (now - last_notice_at) >= notice_every
|
|
):
|
|
try:
|
|
on_wait(max(0.0, now - wait_started))
|
|
except Exception:
|
|
logger.debug(
|
|
"session turn lease on_wait callback failed",
|
|
exc_info=True,
|
|
)
|
|
last_notice_at = now
|
|
time.sleep(min(max(0.01, float(poll_interval_seconds)), remaining))
|
|
|
|
def refresh_session_turn_lease(
|
|
self,
|
|
session_id: str,
|
|
holder: str,
|
|
*,
|
|
ttl_seconds: float = 300.0,
|
|
) -> bool:
|
|
"""Extend a turn lease only while ``holder`` still owns it."""
|
|
if not session_id or not holder:
|
|
return False
|
|
expires_at = time.time() + max(0.1, float(ttl_seconds))
|
|
|
|
def _do(conn):
|
|
conversation_id = self._session_turn_lease_key_on_conn(conn, session_id)
|
|
cursor = conn.execute(
|
|
"UPDATE session_turn_leases SET expires_at = ? "
|
|
"WHERE conversation_id = ? AND holder = ?",
|
|
(expires_at, conversation_id, holder),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
return bool(self._execute_write(_do))
|
|
|
|
def release_session_turn_lease(self, session_id: str, holder: str) -> None:
|
|
"""Release a turn lease iff ``holder`` still owns it; idempotent."""
|
|
if not session_id or not holder:
|
|
return
|
|
|
|
def _do(conn):
|
|
conversation_id = self._session_turn_lease_key_on_conn(conn, session_id)
|
|
conn.execute(
|
|
"DELETE FROM session_turn_leases "
|
|
"WHERE conversation_id = ? AND holder = ?",
|
|
(conversation_id, holder),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def get_compression_lock_holder(self, session_id: str) -> Optional[str]:
|
|
"""Return the current (non-expired) holder for ``session_id``, or None.
|
|
|
|
Diagnostic helper — not used by the locking protocol itself.
|
|
"""
|
|
if not session_id:
|
|
return None
|
|
now = time.time()
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT holder FROM compression_locks "
|
|
"WHERE session_id = ? AND expires_at >= ?",
|
|
(session_id, now),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return row["holder"] if isinstance(row, sqlite3.Row) else row[0]
|
|
|
|
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
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"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),
|
|
)
|
|
|
|
# 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._execute_write(_do, 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:
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT last_activity_description, last_activity_provenance "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
except sqlite3.Error:
|
|
row = None
|
|
if row is not None:
|
|
desc = row[0] if not isinstance(row, sqlite3.Row) else row["last_activity_description"]
|
|
prov = row[1] if not isinstance(row, sqlite3.Row) else row["last_activity_provenance"]
|
|
if not desc and (
|
|
not prov or prov == ActivityProvenance.UNKNOWN.value
|
|
):
|
|
return
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET "
|
|
"last_activity_description = ?, "
|
|
"last_activity_provenance = ? "
|
|
"WHERE id = ?",
|
|
("", ActivityProvenance.UNKNOWN.value, session_id),
|
|
)
|
|
|
|
self._execute_write(_do, 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()
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
|
(model_config_json, model, session_id),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
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_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["model_config"] if isinstance(row, sqlite3.Row) else 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"))
|
|
|
|
@staticmethod
|
|
def session_gateway_runtime(session_meta: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""Read the persisted runtime route off a session row dict.
|
|
|
|
Accepts the dict returned by ``get_session`` (``model_config`` is a
|
|
JSON string) or an already-parsed dict. Prefers the nested
|
|
``gateway_runtime`` key (written by the gateway's
|
|
``_sync_session_model_from_agent`` and the CLI ``/model`` persist),
|
|
falling back to the top-level ``provider``/``base_url``/``api_mode``
|
|
keys the TUI gateway's ``_runtime_model_config`` writes. As a last
|
|
resort, falls back to the ``billing_provider`` column (written on
|
|
every session's first accounted API call) so sessions that never ran
|
|
``/model`` still restore the provider that actually served them.
|
|
Returns an empty dict on any parse failure — resume falls back to
|
|
ambient config resolution.
|
|
"""
|
|
raw = (session_meta or {}).get("model_config")
|
|
if isinstance(raw, str):
|
|
try:
|
|
raw = json.loads(raw)
|
|
except Exception:
|
|
raw = {}
|
|
if not isinstance(raw, dict):
|
|
raw = {}
|
|
runtime = raw.get("gateway_runtime")
|
|
if isinstance(runtime, dict) and runtime.get("provider"):
|
|
# Filter None values: the persist path writes or-None to trigger
|
|
# deletion in the top-level merge, but gateway_runtime is replaced
|
|
# as a whole dict (not deep-merged), so None values survive here.
|
|
return {k: v for k, v in runtime.items() if v is not None}
|
|
top_level = {
|
|
key: raw.get(key)
|
|
for key in ("provider", "base_url", "api_mode")
|
|
if raw.get(key)
|
|
}
|
|
if top_level:
|
|
return top_level
|
|
# Last resort: billing_provider column. Written via COALESCE on every
|
|
# session's first accounted API call — the only durable record for
|
|
# sessions that never ran /model. Mirrors the TUI gateway's
|
|
# _stored_session_runtime_overrides fallback. Bare billing buckets
|
|
# ("auto"/"custom") are not routable identities — filter them out so
|
|
# resume falls back to the ambient config default instead.
|
|
billing_provider = str(
|
|
(session_meta or {}).get("billing_provider") or ""
|
|
).strip()
|
|
if (
|
|
billing_provider
|
|
and billing_provider.lower() not in _BARE_BILLING_PROVIDERS
|
|
):
|
|
return {"provider": billing_provider}
|
|
return {k: v for k, v in (runtime or {}).items() if v is not None} if isinstance(runtime, dict) else {}
|
|
|
|
def update_session_billing_route(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
provider: str,
|
|
base_url: str,
|
|
billing_mode: Optional[str] = None,
|
|
) -> None:
|
|
"""Unconditionally update the billing provider/base_url for a session.
|
|
|
|
Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)``
|
|
(only filling in NULL), this unconditionally sets the billing fields so
|
|
that the dashboard reflects the user's latest /model switch.
|
|
|
|
Also nulls ``system_prompt`` so the cached snapshot (which embeds a
|
|
stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the
|
|
behavior of ``update_session_model`` (see #48173, #48248).
|
|
"""
|
|
# Barrier against queued token deltas — see update_session_model.
|
|
self.flush_token_counts()
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"""UPDATE sessions SET
|
|
billing_provider = ?,
|
|
billing_base_url = ?,
|
|
billing_mode = COALESCE(?, billing_mode),
|
|
system_prompt = NULL,
|
|
system_prompt_hash = NULL
|
|
WHERE id = ?""",
|
|
(provider, base_url, billing_mode, session_id),
|
|
)
|
|
self._delete_unreferenced_system_prompts(conn)
|
|
self._execute_write(_do)
|
|
|
|
# ── 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 queue_token_counts(self, session_id: str, **kwargs) -> None:
|
|
"""Enqueue a token/cost delta for the background writer.
|
|
|
|
Accepts the same keyword arguments as :meth:`update_token_counts`
|
|
and applies them asynchronously with identical semantics. Cheap
|
|
(append + notify) — safe to call on the turn thread after every
|
|
API call. After close() has stopped the writer, falls back to the
|
|
synchronous path and may raise like :meth:`update_token_counts`.
|
|
"""
|
|
with self._token_queue_cond:
|
|
thread = self._token_writer_thread
|
|
writer_stopped = self._token_writer_stop and (
|
|
thread is None or not thread.is_alive()
|
|
)
|
|
if not writer_stopped:
|
|
self._token_queue.append((session_id, kwargs))
|
|
if thread is None or not thread.is_alive():
|
|
# Daemon so process exit never hangs on accounting; the
|
|
# atexit hook drains anything still queued at interpreter
|
|
# shutdown (registered once per instance, on first use).
|
|
# ``not is_alive()`` (rather than ``is None`` only)
|
|
# respawns the writer if it ever died from an unexpected
|
|
# escape — otherwise a dead thread object would block
|
|
# respawn forever and deltas would pile up on the deque
|
|
# until a reader's flush drained them synchronously.
|
|
thread = threading.Thread(
|
|
target=self._token_writer_loop,
|
|
name="session-db-token-writer",
|
|
daemon=True,
|
|
)
|
|
self._token_writer_thread = thread
|
|
thread.start()
|
|
if self._token_atexit_hook is None:
|
|
self_ref = weakref.ref(self)
|
|
|
|
def _drain_at_exit() -> None:
|
|
db = self_ref()
|
|
if db is not None:
|
|
db._drain_token_queue_at_exit()
|
|
|
|
self._token_atexit_hook = _drain_at_exit
|
|
atexit.register(_drain_at_exit)
|
|
self._token_queue_cond.notify_all()
|
|
if writer_stopped:
|
|
# Writer permanently stopped (close() ran; a stop-flagged but
|
|
# still-live writer keeps accepting — its loop drains before
|
|
# exiting). Enqueueing now would drop the delta silently: no
|
|
# writer will run and close() already unregistered the atexit
|
|
# hook. Apply inline instead so a closed-connection failure
|
|
# raises at the call site, exactly like the old synchronous
|
|
# update_token_counts path these call sites still guard for.
|
|
self.update_token_counts(session_id, **kwargs)
|
|
|
|
def flush_token_counts(self, timeout: float = 5.0) -> bool:
|
|
"""Block until every queued token delta has been applied.
|
|
|
|
Returns True when the queue is fully drained, False on timeout
|
|
(callers then read totals that are stale by the still-queued
|
|
deltas — no worse than reading before the flush existed).
|
|
Never raises: apply failures are logged by the writer.
|
|
"""
|
|
# Fast path — nothing queued, nothing in flight.
|
|
if not self._token_queue and not self._token_writer_busy:
|
|
return True
|
|
batch = None
|
|
with self._token_queue_cond:
|
|
deadline = time.monotonic() + timeout
|
|
while self._token_queue or self._token_writer_busy:
|
|
# A live writer is authoritative even when stop-flagged
|
|
# (close() in progress): its loop drains the queue before
|
|
# exiting, and draining here instead would race its
|
|
# in-flight batch — newer deltas committing before older
|
|
# ones breaks the last-non-None-wins / first-accounted-
|
|
# route / COALESCE-backfill fields. Only when the writer is
|
|
# dead (or never started for these deltas) does the caller
|
|
# take the leftovers. Re-checked each wakeup: the writer
|
|
# can exit mid-wait with deltas enqueued after its final
|
|
# empty-queue check. busy is claimed while draining (same
|
|
# protocol as the writer) so a concurrent flush cannot
|
|
# report drained — or pop a newer delta — while this batch
|
|
# is still unapplied; a claimed busy therefore also means
|
|
# "wait", never "drain alongside".
|
|
thread = self._token_writer_thread
|
|
if (
|
|
(thread is None or not thread.is_alive())
|
|
and not self._token_writer_busy
|
|
):
|
|
self._token_writer_busy = True
|
|
batch = list(self._token_queue)
|
|
self._token_queue.clear()
|
|
break
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return False
|
|
self._token_queue_cond.wait(remaining)
|
|
if batch:
|
|
try:
|
|
self._apply_token_batch(batch)
|
|
finally:
|
|
with self._token_queue_cond:
|
|
self._token_writer_busy = False
|
|
self._token_queue_cond.notify_all()
|
|
return True
|
|
|
|
def _token_writer_loop(self) -> None:
|
|
while True:
|
|
with self._token_queue_cond:
|
|
idle_deadline = time.monotonic() + self._TOKEN_WRITER_IDLE_SECONDS
|
|
while not self._token_queue and not self._token_writer_stop:
|
|
remaining = idle_deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
# Publish retirement under the same lock used by
|
|
# queue_token_counts() to decide whether to spawn. An
|
|
# enqueue cannot strand a delta behind an exiting worker.
|
|
self._token_writer_thread = None
|
|
return
|
|
self._token_queue_cond.wait(remaining)
|
|
if not self._token_queue:
|
|
self._token_writer_thread = None
|
|
return # stop requested and fully drained
|
|
# busy is set BEFORE the queue is cleared: the lock-free
|
|
# fast path in flush_token_counts() reads queue-then-busy,
|
|
# so this order guarantees it can never observe an empty
|
|
# queue while the popped batch is still unapplied.
|
|
self._token_writer_busy = True
|
|
batch = list(self._token_queue)
|
|
self._token_queue.clear()
|
|
try:
|
|
self._apply_token_batch(batch)
|
|
finally:
|
|
with self._token_queue_cond:
|
|
self._token_writer_busy = False
|
|
self._token_queue_cond.notify_all()
|
|
|
|
def _apply_token_batch(self, batch: List[Tuple[str, Dict[str, Any]]]) -> None:
|
|
"""Apply queued deltas in order, coalescing where safe. Never raises."""
|
|
try:
|
|
coalesced = self._coalesce_token_deltas(batch)
|
|
except Exception as exc:
|
|
# Coalescing must never kill the writer thread (a dead writer
|
|
# can't be observed by callers). Fall back to applying the raw
|
|
# batch delta-by-delta — the merge is an optimization only.
|
|
logger.warning(
|
|
"async token accounting: coalesce failed, applying raw "
|
|
"batch: %s", exc,
|
|
)
|
|
coalesced = batch
|
|
for session_id, kwargs in coalesced:
|
|
try:
|
|
self.update_token_counts(session_id, **kwargs)
|
|
except Exception as exc:
|
|
# Same contract as the old inline call sites: accounting
|
|
# loss is logged, never raised into a turn.
|
|
logger.warning(
|
|
"async token accounting: apply failed (session=%s): %s",
|
|
session_id, exc,
|
|
)
|
|
|
|
def _coalesce_token_deltas(
|
|
self, batch: List[Tuple[str, Dict[str, Any]]]
|
|
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
"""Merge consecutive incremental deltas with an identical route.
|
|
|
|
Only adjacent deltas merge, so ordering across sessions and across
|
|
a mid-session /model switch is preserved exactly. absolute=True
|
|
deltas (cumulative overwrites) never merge.
|
|
"""
|
|
groups: List[Tuple[Optional[tuple], str, Dict[str, Any]]] = []
|
|
for session_id, kwargs in batch:
|
|
key = None
|
|
if not kwargs.get("absolute"):
|
|
key = (session_id,) + tuple(
|
|
kwargs.get(f) for f in self._TOKEN_DELTA_ROUTE_FIELDS
|
|
)
|
|
if groups and key is not None and groups[-1][0] == key:
|
|
merged = groups[-1][2]
|
|
for f in self._TOKEN_DELTA_SUM_FIELDS:
|
|
merged[f] = merged.get(f, 0) + kwargs.get(f, 0)
|
|
for f in self._TOKEN_DELTA_COST_FIELDS:
|
|
value = kwargs.get(f)
|
|
if value is not None:
|
|
# None-preserving sum: an all-None run must stay
|
|
# None so COALESCE keeps the stored value untouched.
|
|
merged[f] = (merged.get(f) or 0.0) + value
|
|
else:
|
|
groups.append((key, session_id, dict(kwargs)))
|
|
return [(sid, kw) for _, sid, kw in groups]
|
|
|
|
def _stop_token_writer(self, join_timeout: float = 10.0) -> None:
|
|
"""Stop the writer thread and drain remaining deltas. Never raises."""
|
|
with self._token_queue_cond:
|
|
self._token_writer_stop = True
|
|
self._token_queue_cond.notify_all()
|
|
thread = self._token_writer_thread
|
|
if thread is not None and thread.is_alive():
|
|
thread.join(timeout=join_timeout)
|
|
if thread.is_alive():
|
|
# Writer stuck mid-apply (pathological lock contention).
|
|
# Leave any queued deltas unapplied rather than racing the
|
|
# stuck apply and misordering/double-counting.
|
|
logger.warning(
|
|
"async token accounting: writer did not stop within %.0fs; "
|
|
"%d queued delta(s) not persisted",
|
|
join_timeout, len(self._token_queue),
|
|
)
|
|
return
|
|
# Writer exited (or never started) — apply leftovers synchronously.
|
|
# Claim busy like the writer/flush drains do, so a concurrent
|
|
# flush_token_counts cannot fast-path True while this batch is
|
|
# still being applied; conversely, wait out a flush caller-drain
|
|
# that already claimed busy — close() nulls the connection right
|
|
# after this returns, and must not yank it mid-batch.
|
|
with self._token_queue_cond:
|
|
deadline = time.monotonic() + join_timeout
|
|
while self._token_writer_busy:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
logger.warning(
|
|
"async token accounting: concurrent drain did not "
|
|
"finish within %.0fs; %d queued delta(s) not persisted",
|
|
join_timeout, len(self._token_queue),
|
|
)
|
|
return
|
|
self._token_queue_cond.wait(remaining)
|
|
# busy is claimed BEFORE the queue is cleared — same ordering
|
|
# as the writer loop and the flush caller-drain. The lock-free
|
|
# fast path in flush_token_counts() reads queue-then-busy
|
|
# without the cond, so clearing first would let a concurrent
|
|
# flush observe "empty and idle" and return True while this
|
|
# popped batch is still unapplied.
|
|
batch = list(self._token_queue)
|
|
if batch:
|
|
self._token_writer_busy = True
|
|
self._token_queue.clear()
|
|
if batch:
|
|
try:
|
|
self._apply_token_batch(batch)
|
|
finally:
|
|
with self._token_queue_cond:
|
|
self._token_writer_busy = False
|
|
self._token_queue_cond.notify_all()
|
|
|
|
def _drain_token_queue_at_exit(self) -> None:
|
|
try:
|
|
self._stop_token_writer()
|
|
except Exception:
|
|
pass # Best effort — never fatal at interpreter shutdown.
|
|
|
|
def update_token_counts(
|
|
self,
|
|
session_id: str,
|
|
input_tokens: int = 0,
|
|
output_tokens: int = 0,
|
|
model: str = None,
|
|
cache_read_tokens: int = 0,
|
|
cache_write_tokens: int = 0,
|
|
reasoning_tokens: int = 0,
|
|
estimated_cost_usd: Optional[float] = None,
|
|
actual_cost_usd: Optional[float] = None,
|
|
cost_status: Optional[str] = None,
|
|
cost_source: Optional[str] = None,
|
|
pricing_version: Optional[str] = None,
|
|
billing_provider: Optional[str] = None,
|
|
billing_base_url: Optional[str] = None,
|
|
billing_mode: Optional[str] = None,
|
|
api_call_count: int = 0,
|
|
absolute: bool = False,
|
|
) -> None:
|
|
"""Update token counters and backfill model if not already set.
|
|
|
|
When *absolute* is False (default), values are **incremented** — use
|
|
this for per-API-call deltas (CLI path).
|
|
|
|
When *absolute* is True, values are **set directly** — use this when
|
|
the caller already holds cumulative totals (gateway path, where the
|
|
cached agent accumulates across messages).
|
|
"""
|
|
# Ensure the session row exists so the UPDATE doesn't silently affect
|
|
# 0 rows. Under concurrent load (cron + kanban + delegate_task) the
|
|
# initial create_session() may have failed due to SQLite locking.
|
|
# INSERT OR IGNORE is cheap and idempotent.
|
|
self._insert_session_row(session_id, "unknown", model=model)
|
|
if absolute:
|
|
sql = """UPDATE sessions SET
|
|
input_tokens = ?,
|
|
output_tokens = ?,
|
|
cache_read_tokens = ?,
|
|
cache_write_tokens = ?,
|
|
reasoning_tokens = ?,
|
|
estimated_cost_usd = COALESCE(?, 0),
|
|
actual_cost_usd = CASE
|
|
WHEN ? IS NULL THEN actual_cost_usd
|
|
ELSE ?
|
|
END,
|
|
cost_status = COALESCE(?, cost_status),
|
|
cost_source = COALESCE(?, cost_source),
|
|
pricing_version = COALESCE(?, pricing_version),
|
|
billing_provider = COALESCE(billing_provider, ?),
|
|
billing_base_url = COALESCE(billing_base_url, ?),
|
|
billing_mode = COALESCE(billing_mode, ?),
|
|
model = COALESCE(model, ?),
|
|
api_call_count = ?
|
|
WHERE id = ?"""
|
|
else:
|
|
sql = """UPDATE sessions SET
|
|
input_tokens = input_tokens + ?,
|
|
output_tokens = output_tokens + ?,
|
|
cache_read_tokens = cache_read_tokens + ?,
|
|
cache_write_tokens = cache_write_tokens + ?,
|
|
reasoning_tokens = reasoning_tokens + ?,
|
|
estimated_cost_usd = COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0),
|
|
actual_cost_usd = CASE
|
|
WHEN ? IS NULL THEN actual_cost_usd
|
|
ELSE COALESCE(actual_cost_usd, 0) + ?
|
|
END,
|
|
cost_status = COALESCE(?, cost_status),
|
|
cost_source = COALESCE(?, cost_source),
|
|
pricing_version = COALESCE(?, pricing_version),
|
|
billing_provider = COALESCE(billing_provider, ?),
|
|
billing_base_url = COALESCE(billing_base_url, ?),
|
|
billing_mode = COALESCE(billing_mode, ?),
|
|
model = COALESCE(model, ?),
|
|
api_call_count = COALESCE(api_call_count, 0) + ?
|
|
WHERE id = ?"""
|
|
has_accounted_usage = bool(
|
|
input_tokens or output_tokens or cache_read_tokens
|
|
or cache_write_tokens or reasoning_tokens or api_call_count
|
|
or estimated_cost_usd or actual_cost_usd
|
|
)
|
|
params = (
|
|
input_tokens,
|
|
output_tokens,
|
|
cache_read_tokens,
|
|
cache_write_tokens,
|
|
reasoning_tokens,
|
|
estimated_cost_usd,
|
|
actual_cost_usd,
|
|
actual_cost_usd,
|
|
cost_status,
|
|
cost_source,
|
|
pricing_version,
|
|
billing_provider if has_accounted_usage else None,
|
|
billing_base_url if has_accounted_usage else None,
|
|
billing_mode if has_accounted_usage else None,
|
|
model if has_accounted_usage else None,
|
|
api_call_count,
|
|
session_id,
|
|
)
|
|
# Per-model usage attribution. ``update_token_counts`` is the single
|
|
# chokepoint every per-API-call delta flows through (CLI, gateway, cron,
|
|
# delegated runs — see conversation_loop / codex_runtime), and each call
|
|
# carries the model/provider *active at the time of that call*. The
|
|
# ``sessions`` row only keeps one (model, billing_provider) pair, so a
|
|
# mid-session ``/model`` switch otherwise attributes every token to the
|
|
# initial model (issue #51607). Recording the per-call delta into
|
|
# session_model_usage keyed by the live model preserves an accurate
|
|
# per-model breakdown regardless of how many times the user switches.
|
|
#
|
|
# Only the incremental path records here. Absolute cumulative updates
|
|
# cannot be split back into routes; Insights reconciles any positive
|
|
# residual against the aggregate session row instead.
|
|
record_model_usage = (not absolute) and (
|
|
input_tokens or output_tokens or cache_read_tokens
|
|
or cache_write_tokens or reasoning_tokens or api_call_count
|
|
or estimated_cost_usd
|
|
)
|
|
|
|
def _do(conn):
|
|
row = conn.execute(
|
|
"SELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
existing_model = row["model"] if row is not None else None
|
|
existing_provider = row["billing_provider"] if row is not None else None
|
|
existing_api_calls = int((row["api_call_count"] if row is not None else 0) or 0)
|
|
|
|
# Session creation records the requested primary route before any API
|
|
# call. If it fails and fallback succeeds, the first accounted usage
|
|
# event is the first authoritative route. After that, preserve the
|
|
# legacy row: one row cannot represent mixed-provider usage.
|
|
first_accounted_route = (
|
|
existing_api_calls == 0
|
|
and has_accounted_usage
|
|
and bool(model)
|
|
and bool(billing_provider)
|
|
and (existing_model != model or existing_provider != billing_provider)
|
|
)
|
|
if first_accounted_route:
|
|
conn.execute(
|
|
"""UPDATE sessions
|
|
SET model = ?, billing_provider = ?,
|
|
billing_base_url = ?, billing_mode = ?
|
|
WHERE id = ?""",
|
|
(model, billing_provider, billing_base_url, billing_mode, session_id),
|
|
)
|
|
conn.execute(sql, params)
|
|
if record_model_usage:
|
|
self._record_model_usage(
|
|
conn,
|
|
session_id,
|
|
model=model,
|
|
billing_provider=billing_provider,
|
|
billing_base_url=billing_base_url,
|
|
billing_mode=billing_mode,
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
cache_read_tokens=cache_read_tokens,
|
|
cache_write_tokens=cache_write_tokens,
|
|
reasoning_tokens=reasoning_tokens,
|
|
estimated_cost_usd=estimated_cost_usd,
|
|
actual_cost_usd=actual_cost_usd,
|
|
cost_status=cost_status,
|
|
cost_source=cost_source,
|
|
api_call_count=api_call_count,
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def _record_model_usage(
|
|
self,
|
|
conn,
|
|
session_id: str,
|
|
*,
|
|
model: Optional[str],
|
|
billing_provider: Optional[str],
|
|
billing_base_url: Optional[str],
|
|
billing_mode: Optional[str],
|
|
input_tokens: int,
|
|
output_tokens: int,
|
|
cache_read_tokens: int,
|
|
cache_write_tokens: int,
|
|
reasoning_tokens: int,
|
|
estimated_cost_usd: Optional[float],
|
|
actual_cost_usd: Optional[float],
|
|
cost_status: Optional[str],
|
|
cost_source: Optional[str],
|
|
api_call_count: int,
|
|
task: str = "",
|
|
) -> None:
|
|
"""Accumulate a per-API-call usage delta into session_model_usage.
|
|
|
|
Runs inside the caller's write transaction (after the ``sessions``
|
|
UPDATE) so the per-model rows stay consistent with the summary row.
|
|
When the caller omits the model/provider (some paths only pass token
|
|
deltas), fall back to the values already recorded on the session row —
|
|
the same COALESCE-from-session behaviour the summary update uses.
|
|
|
|
``task`` distinguishes what kind of work consumed the tokens:
|
|
``''`` (empty) is the main agent loop; auxiliary calls record their
|
|
task name (``vision``, ``compression``, ``title_generation``, ...)
|
|
via :meth:`record_auxiliary_usage` (issue #23270).
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT model, billing_provider, billing_base_url, billing_mode "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
sess_model = row["model"] if row is not None else None
|
|
sess_provider = row["billing_provider"] if row is not None else None
|
|
sess_base_url = row["billing_base_url"] if row is not None else None
|
|
sess_billing_mode = row["billing_mode"] if row is not None else None
|
|
|
|
# Aux-task rows (task != '') must NOT inherit the session's main-loop
|
|
# route: an aux call may use a completely different provider/model
|
|
# (vision on gemini while the main loop runs anthropic). Missing info
|
|
# stays 'unknown'/empty rather than borrowing a misleading route.
|
|
if task:
|
|
eff_model = model or "unknown"
|
|
eff_provider = billing_provider or ""
|
|
eff_base_url = billing_base_url or ""
|
|
eff_billing_mode = billing_mode or ""
|
|
else:
|
|
eff_model = model or sess_model or "unknown"
|
|
eff_provider = billing_provider or sess_provider or ""
|
|
eff_base_url = billing_base_url or sess_base_url or ""
|
|
eff_billing_mode = billing_mode or sess_billing_mode or ""
|
|
now = time.time()
|
|
conn.execute(
|
|
"""INSERT INTO session_model_usage (
|
|
session_id, model, billing_provider, billing_base_url, billing_mode,
|
|
task, api_call_count, input_tokens, output_tokens,
|
|
cache_read_tokens, cache_write_tokens, reasoning_tokens,
|
|
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
|
|
first_seen, last_seen
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode, task)
|
|
DO UPDATE SET
|
|
api_call_count = api_call_count + excluded.api_call_count,
|
|
input_tokens = input_tokens + excluded.input_tokens,
|
|
output_tokens = output_tokens + excluded.output_tokens,
|
|
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
|
|
reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens,
|
|
estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,
|
|
actual_cost_usd = actual_cost_usd + excluded.actual_cost_usd,
|
|
cost_status = COALESCE(excluded.cost_status, cost_status),
|
|
cost_source = COALESCE(excluded.cost_source, cost_source),
|
|
last_seen = excluded.last_seen""",
|
|
(
|
|
session_id,
|
|
eff_model,
|
|
eff_provider,
|
|
eff_base_url,
|
|
eff_billing_mode,
|
|
task or "",
|
|
api_call_count or 0,
|
|
input_tokens or 0,
|
|
output_tokens or 0,
|
|
cache_read_tokens or 0,
|
|
cache_write_tokens or 0,
|
|
reasoning_tokens or 0,
|
|
float(estimated_cost_usd or 0.0),
|
|
float(actual_cost_usd or 0.0),
|
|
cost_status,
|
|
cost_source,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
|
|
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
|
|
|
|
def record_auxiliary_usage(
|
|
self,
|
|
session_id: str,
|
|
task: str,
|
|
*,
|
|
model: Optional[str] = None,
|
|
billing_provider: Optional[str] = None,
|
|
billing_base_url: Optional[str] = None,
|
|
input_tokens: int = 0,
|
|
output_tokens: int = 0,
|
|
cache_read_tokens: int = 0,
|
|
cache_write_tokens: int = 0,
|
|
reasoning_tokens: int = 0,
|
|
estimated_cost_usd: Optional[float] = None,
|
|
api_call_count: int = 1,
|
|
) -> None:
|
|
"""Record an auxiliary LLM call's usage against *session_id* (issue #23270).
|
|
|
|
Auxiliary calls (vision, compression, title_generation, web_extract,
|
|
session_search, ...) historically discarded their usage, leaving the
|
|
dashboard's per-model analytics blind to aux model spend. This writes
|
|
a per-(model, provider, task) delta into ``session_model_usage`` —
|
|
the same table the main loop's ``update_token_counts`` feeds — WITHOUT
|
|
touching the ``sessions`` summary row. That separation is deliberate:
|
|
the gateway overwrites session counters with absolute main-loop totals,
|
|
so folding aux tokens into the summary row would either be clobbered
|
|
or double-counted. Insights/analytics read the union of both.
|
|
|
|
``api_call_count`` defaults to 1 (one aux LLM call). Background-review
|
|
forks record an aggregate of N fork API calls in one write with
|
|
``task='background_review'`` (issue #87250).
|
|
|
|
Best-effort by contract: callers must never fail an aux call because
|
|
accounting failed.
|
|
"""
|
|
if not session_id or not task:
|
|
return
|
|
# FK on session_model_usage.session_id → sessions.id: ensure the row
|
|
# exists (same INSERT OR IGNORE guard update_token_counts uses — the
|
|
# initial create_session() can fail under concurrent SQLite locking).
|
|
self._insert_session_row(session_id, "unknown")
|
|
|
|
def _do(conn):
|
|
self._record_model_usage(
|
|
conn,
|
|
session_id,
|
|
model=model,
|
|
billing_provider=billing_provider,
|
|
billing_base_url=billing_base_url,
|
|
billing_mode=None,
|
|
input_tokens=input_tokens or 0,
|
|
output_tokens=output_tokens or 0,
|
|
cache_read_tokens=cache_read_tokens or 0,
|
|
cache_write_tokens=cache_write_tokens or 0,
|
|
reasoning_tokens=reasoning_tokens or 0,
|
|
estimated_cost_usd=estimated_cost_usd,
|
|
actual_cost_usd=None,
|
|
cost_status=None,
|
|
cost_source=None,
|
|
api_call_count=(
|
|
1 if api_call_count is None else int(api_call_count)
|
|
),
|
|
task=task,
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def prune_empty_ghost_sessions(self, sessions_dir: "Optional[Path]" = None) -> int:
|
|
"""Remove empty TUI ghost sessions (no messages, no title, >24hr old)."""
|
|
cutoff = time.time() - 86400 # Only sessions older than 24 hours
|
|
|
|
def _do(conn):
|
|
rows = conn.execute("""
|
|
SELECT id FROM sessions
|
|
WHERE source = 'tui'
|
|
AND title IS NULL
|
|
AND ended_at IS NOT NULL
|
|
AND started_at < ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
|
|
)
|
|
""", (cutoff,)).fetchall()
|
|
ids = [r[0] if isinstance(r, (tuple, list)) else r["id"] for r in rows]
|
|
if ids:
|
|
placeholders = ",".join("?" * len(ids))
|
|
conn.execute(
|
|
f"DELETE FROM sessions WHERE id IN ({placeholders})", ids
|
|
)
|
|
self._delete_unreferenced_system_prompts(conn)
|
|
return ids
|
|
|
|
removed_ids = self._execute_write(_do) or []
|
|
# Clean up any on-disk session files (belt-and-suspenders)
|
|
if sessions_dir and removed_ids:
|
|
for sid in removed_ids:
|
|
self._remove_session_files(sessions_dir, sid)
|
|
return len(removed_ids)
|
|
|
|
def finalize_orphaned_compression_sessions(self) -> int:
|
|
"""Mark orphaned compression continuation sessions as ended.
|
|
|
|
Targets child sessions that were never finalized: parent is ended
|
|
with reason='compression', child has messages but no end_reason/ended_at
|
|
and api_call_count=0. Non-destructive: preserves all messages and sets
|
|
end_reason='orphaned_compression'. Fix for #20001.
|
|
"""
|
|
cutoff = time.time() - 604800 # 7 days
|
|
|
|
def _do(conn):
|
|
now = time.time()
|
|
result = conn.execute(
|
|
"""
|
|
UPDATE sessions
|
|
SET ended_at = ?,
|
|
end_reason = 'orphaned_compression'
|
|
WHERE api_call_count = 0
|
|
AND end_reason IS NULL
|
|
AND ended_at IS NULL
|
|
AND started_at < ?
|
|
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'
|
|
AND p.ended_at IS NOT NULL
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1 FROM messages m
|
|
WHERE m.session_id = sessions.id
|
|
)
|
|
""",
|
|
(now, cutoff),
|
|
)
|
|
return result.rowcount
|
|
|
|
return self._execute_write(_do) or 0
|
|
|
|
def sweep_orphaned_sessions(
|
|
self,
|
|
*,
|
|
max_idle_seconds: float,
|
|
sources: Tuple[str, ...] = ("tui", "desktop", "subagent"),
|
|
exclude_ids: Tuple[str, ...] = (),
|
|
heartbeat_staleness_seconds: Optional[float] = None,
|
|
heartbeat_ownership_grace_seconds: Optional[float] = None,
|
|
) -> List[str]:
|
|
"""Close session rows orphaned by a dead gateway process (#65194, #94895).
|
|
|
|
The TUI/desktop gateway reaps disconnected websocket sessions with an
|
|
in-process ``threading.Timer`` grace timer; a gateway restart destroys
|
|
the timer and leaves the row ``ended_at IS NULL`` forever. This is
|
|
the startup-time complement: it closes rows for the given ``sources``
|
|
whose ``started_at`` AND newest ``messages.timestamp`` are both older
|
|
than ``max_idle_seconds``, with a distinct
|
|
``end_reason='startup_orphan_reap'`` for traceability.
|
|
|
|
Both timestamps must be stale on purpose: message recency alone would
|
|
sweep a freshly created compression/branch child carrying old copied
|
|
message timestamps, while ``started_at`` alone would sweep a
|
|
long-lived session that is still actively producing messages.
|
|
Message-less rows fall back to ``started_at`` via COALESCE.
|
|
|
|
Only pass sources owned by the local UI stack (never messaging-gateway
|
|
platforms like ``telegram`` — ending those triggers the #60609 routing
|
|
loop). ``exclude_ids`` spares rows this process still holds in
|
|
memory (a ``session.resume`` that landed during the startup grace
|
|
window). Non-destructive: messages are preserved and the row remains
|
|
resumable. First-reason-wins is preserved via ``ended_at IS NULL``.
|
|
|
|
Cross-backend liveness (#94895): when one ``state.db`` is shared by
|
|
N serve / gateway processes (isolated backends, fixed-port launchd
|
|
``hermes serve``, desktop WS sidecar), each backend registers a row
|
|
in ``gateway_heartbeats`` refreshed every few seconds. A row is
|
|
only reaped when ``started_at``/message staleness hold AND no live
|
|
backend (heartbeat refreshed within ``heartbeat_staleness_seconds``,
|
|
default ``2 * max_idle_seconds``) could plausibly own it.
|
|
|
|
Ownership inference: a live backend B ``owns`` a session S if
|
|
``B.started_at <= S.started_at + heartbeat_ownership_grace_seconds``
|
|
(default ``heartbeat_staleness_seconds``). The grace window
|
|
accommodates the deploy-time migration case where a backend just
|
|
wrote its first heartbeat row while its existing open sessions
|
|
predate the schema. The grace is bounded by the staleness window
|
|
so a fresh PID-reuse respawn cannot indefinitely protect sessions
|
|
inherited from a dead predecessor.
|
|
|
|
When NO backend has ever written a heartbeat (legacy deployment
|
|
mid-upgrade before any process has registered) the predicate falls
|
|
back to the original behavior so we never silently strand a row
|
|
that pre-dates the schema.
|
|
|
|
The SELECT + UPDATE run in one ``BEGIN IMMEDIATE`` write, so a sibling
|
|
process cannot sneak a new message or end-reason between the
|
|
staleness check and the close. Returns the swept session ids.
|
|
"""
|
|
srcs = tuple(s for s in sources if s)
|
|
if max_idle_seconds <= 0 or not srcs:
|
|
return []
|
|
hb_staleness = (
|
|
heartbeat_staleness_seconds
|
|
if heartbeat_staleness_seconds and heartbeat_staleness_seconds > 0
|
|
else max_idle_seconds * 2
|
|
)
|
|
hb_grace = (
|
|
heartbeat_ownership_grace_seconds
|
|
if heartbeat_ownership_grace_seconds and heartbeat_ownership_grace_seconds >= 0
|
|
else hb_staleness
|
|
)
|
|
cutoff = time.time() - max_idle_seconds
|
|
hb_cutoff = time.time() - hb_staleness
|
|
placeholders = ",".join("?" for _ in srcs)
|
|
staleness = (
|
|
"started_at < ? AND COALESCE((SELECT MAX(m.timestamp) FROM messages m"
|
|
" WHERE m.session_id = sessions.id), started_at) < ?"
|
|
)
|
|
|
|
def _do(conn):
|
|
# Cross-process liveness gate (#94895). A session is "owned by
|
|
# a live backend" if any row in gateway_heartbeats is fresh
|
|
# (last_heartbeat >= hb_cutoff) AND was alive no later than
|
|
# ``sessions.started_at + hb_grace`` (heartbeats.started_at <=
|
|
# sessions.started_at + hb_grace). If at least one live backend
|
|
# matches, the row is not orphaned.
|
|
#
|
|
# ``hb_cutoff`` and ``hb_grace`` are computed above so all
|
|
# backends running concurrent sweep queries agree on the same
|
|
# boundaries. We do NOT clear heartbeats here — that's each
|
|
# backend's atexit responsibility via ``clear_backend_heartbeat``.
|
|
orphan_predicate = (
|
|
f"{staleness} AND NOT EXISTS ("
|
|
"SELECT 1 FROM gateway_heartbeats h"
|
|
" WHERE h.last_heartbeat >= ?"
|
|
f" AND h.started_at <= sessions.started_at + ?"
|
|
")"
|
|
)
|
|
rows = conn.execute(
|
|
f"SELECT id FROM sessions WHERE ended_at IS NULL"
|
|
f" AND source IN ({placeholders}) AND {orphan_predicate}",
|
|
(*srcs, cutoff, cutoff, hb_cutoff, hb_grace),
|
|
).fetchall()
|
|
excluded = {str(x) for x in exclude_ids if x}
|
|
victims = [str(r["id"]) for r in rows if str(r["id"]) not in excluded]
|
|
if not victims:
|
|
return []
|
|
now = time.time()
|
|
marks = ",".join("?" for _ in victims)
|
|
# Re-apply the same predicates under the write lock so a
|
|
# row that raced to activity between SELECT and UPDATE is
|
|
# spared (and so a freshly registered heartbeat from a sibling
|
|
# that started during this transaction can still save the row).
|
|
conn.execute(
|
|
f"UPDATE sessions SET ended_at = ?, end_reason = 'startup_orphan_reap'"
|
|
f" WHERE id IN ({marks}) AND ended_at IS NULL"
|
|
f" AND {orphan_predicate}",
|
|
(now, *victims, cutoff, cutoff, hb_cutoff, hb_grace),
|
|
)
|
|
return victims
|
|
|
|
return self._execute_write(_do) or []
|
|
|
|
# ── 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 register_backend_heartbeat(
|
|
self,
|
|
*,
|
|
backend_id: str,
|
|
pid: int,
|
|
started_at: float,
|
|
last_heartbeat: Optional[float] = None,
|
|
profile: str = "",
|
|
host: str = "",
|
|
) -> None:
|
|
"""Upsert this backend's liveness row (#94895).
|
|
|
|
``backend_id`` MUST be stable for the lifetime of the process
|
|
(e.g. ``f"{profile}@{host}:{pid}"``) so a respawn cannot accidentally
|
|
inherit the dead predecessor's heartbeat and protect stale rows.
|
|
``started_at`` records when THIS process started (not the wall clock
|
|
at first refresh) so a long-lived backend whose previous run died
|
|
cannot be confused with a freshly-spawned sibling.
|
|
"""
|
|
if not backend_id:
|
|
return
|
|
ts = time.time() if last_heartbeat is None else float(last_heartbeat)
|
|
def _do(conn):
|
|
conn.execute(
|
|
"INSERT INTO gateway_heartbeats"
|
|
" (backend_id, pid, started_at, last_heartbeat, profile, host)"
|
|
" VALUES (?, ?, ?, ?, ?, ?)"
|
|
" ON CONFLICT(backend_id) DO UPDATE SET"
|
|
" pid = excluded.pid,"
|
|
" started_at = excluded.started_at,"
|
|
" last_heartbeat = excluded.last_heartbeat,"
|
|
" profile = excluded.profile,"
|
|
" host = excluded.host",
|
|
(str(backend_id), int(pid), float(started_at), ts,
|
|
str(profile), str(host)),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def clear_backend_heartbeat(self, backend_id: str) -> bool:
|
|
"""Remove this backend's heartbeat row (#94895).
|
|
|
|
Called from ``atexit`` so a graceful shutdown doesn't leave a stale
|
|
row behind. A crashed backend's row is reclaimed later by
|
|
``prune_stale_heartbeats``. Returns True if a row was removed.
|
|
"""
|
|
if not backend_id:
|
|
return False
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"DELETE FROM gateway_heartbeats WHERE backend_id = ?",
|
|
(str(backend_id),),
|
|
)
|
|
return cur.rowcount > 0
|
|
return bool(self._execute_write(_do))
|
|
|
|
def prune_stale_heartbeats(self, *, max_age_seconds: float) -> List[str]:
|
|
"""Drop heartbeat rows whose ``last_heartbeat`` is older than the
|
|
staleness window. Returns the removed backend ids. Safe to call
|
|
from any process; only stale rows are touched.
|
|
"""
|
|
if max_age_seconds <= 0:
|
|
return []
|
|
cutoff = time.time() - max_age_seconds
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"DELETE FROM gateway_heartbeats WHERE last_heartbeat < ?"
|
|
" RETURNING backend_id",
|
|
(cutoff,),
|
|
)
|
|
return [str(r[0]) for r in cur.fetchall()]
|
|
return list(self._execute_write(_do) or [])
|
|
|
|
def list_backend_heartbeats(self) -> List[Dict[str, Any]]:
|
|
"""Snapshot of every registered backend's heartbeat (for diagnostics
|
|
and tests). The fields mirror ``gateway_heartbeats`` exactly.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"SELECT backend_id, pid, started_at, last_heartbeat,"
|
|
" profile, host FROM gateway_heartbeats"
|
|
" ORDER BY last_heartbeat DESC"
|
|
).fetchall()
|
|
out: List[Dict[str, Any]] = []
|
|
for r in rows:
|
|
if isinstance(r, sqlite3.Row):
|
|
out.append({k: r[k] for k in r.keys()})
|
|
else:
|
|
out.append({
|
|
"backend_id": r[0], "pid": r[1], "started_at": r[2],
|
|
"last_heartbeat": r[3], "profile": r[4], "host": r[5],
|
|
})
|
|
return 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()
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"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,),
|
|
)
|
|
row = cursor.fetchone()
|
|
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()
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"""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,),
|
|
).fetchone()
|
|
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)
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
|
|
(f"{escaped}%",),
|
|
)
|
|
matches = [row["id"] for row in cursor.fetchall()]
|
|
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 user renames of a
|
|
# hidden row holding it (#92473).
|
|
CANONICAL_BOT_CHAT_TITLE = "Bot Chat"
|
|
|
|
@classmethod
|
|
def _title_rank(cls, source: Optional[str]) -> int:
|
|
"""Rank a stored title_source. NULL means a pre-provenance row.
|
|
|
|
Rows written before this column existed carry NULL. They were almost
|
|
always set by the old auto-titler, but a manual ``/title`` from that
|
|
era is indistinguishable — so treat NULL as ``user`` and refuse to
|
|
overwrite it. Auto-titling only ever fills genuinely empty titles on
|
|
legacy rows, which is the conservative direction.
|
|
"""
|
|
if source is None:
|
|
return cls._TITLE_SOURCE_RANK[cls.TITLE_SOURCE_USER]
|
|
return cls._TITLE_SOURCE_RANK.get(str(source), 0)
|
|
|
|
@staticmethod
|
|
def sanitize_title(title: Optional[str]) -> Optional[str]:
|
|
"""Validate and sanitize a session title.
|
|
|
|
- Strips leading/trailing whitespace
|
|
- Removes ASCII control characters (0x00-0x1F, 0x7F) and problematic
|
|
Unicode control chars (zero-width, RTL/LTR overrides, etc.)
|
|
- Collapses internal whitespace runs to single spaces
|
|
- Normalizes empty/whitespace-only strings to None
|
|
- Enforces MAX_TITLE_LENGTH
|
|
|
|
Returns the cleaned title string or None.
|
|
Raises ValueError if the title exceeds MAX_TITLE_LENGTH after cleaning.
|
|
"""
|
|
if not title:
|
|
return None
|
|
|
|
# Lone surrogates cannot be bound by sqlite3 (UnicodeEncodeError at
|
|
# UTF-8 encode time) — scrub them like every other write path here.
|
|
title = _sanitize_surrogates(title)
|
|
|
|
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
|
|
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
|
|
# normalized to spaces by the whitespace collapsing step below
|
|
cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', title)
|
|
|
|
# Remove problematic Unicode control characters:
|
|
# - Zero-width chars (U+200B-U+200F, U+FEFF)
|
|
# - Directional overrides (U+202A-U+202E, U+2066-U+2069)
|
|
# - Object replacement (U+FFFC), interlinear annotation (U+FFF9-U+FFFB)
|
|
cleaned = re.sub(
|
|
r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]',
|
|
'', cleaned,
|
|
)
|
|
|
|
# Collapse internal whitespace runs and strip
|
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
|
|
|
if not cleaned:
|
|
return None
|
|
|
|
if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
|
|
raise ValueError(
|
|
f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})"
|
|
)
|
|
|
|
return cleaned
|
|
|
|
def _is_compression_ancestor(
|
|
self, conn, *, ancestor_id: str, descendant_id: str
|
|
) -> bool:
|
|
"""Return True if *ancestor_id* is a compression predecessor of
|
|
*descendant_id* (walking parent links up the continuation chain).
|
|
|
|
The continuation edge is the canonical one shared with
|
|
:func:`_ephemeral_child_sql` / :meth:`set_session_archived`
|
|
(``_COMPRESSION_CHILD_SQL``): a parent → child edge counts only when the
|
|
parent ended with ``end_reason = 'compression'`` and the child started
|
|
at or after the parent's ``ended_at``, which distinguishes continuations
|
|
from delegate subagents / branch children that also carry a
|
|
``parent_session_id``. Expressed as a single recursive CTE rather than a
|
|
per-hop Python walk so the edge definition lives in exactly one place.
|
|
"""
|
|
if not ancestor_id or not descendant_id or ancestor_id == descendant_id:
|
|
return False
|
|
# Walk parent links up from the descendant, following only compression
|
|
# continuation edges, and check whether ancestor_id is reached.
|
|
edge = _COMPRESSION_CHILD_SQL.format(a="child")
|
|
row = conn.execute(
|
|
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 {edge}
|
|
)
|
|
SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
|
|
""",
|
|
(descendant_id, ancestor_id, descendant_id),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
def _set_session_title(
|
|
self,
|
|
session_id: str,
|
|
title: str,
|
|
*,
|
|
source: str,
|
|
) -> bool:
|
|
"""Write a title, enforcing provenance precedence.
|
|
|
|
``source`` is one of ``TITLE_SOURCE_{DERIVED,LLM,USER}``. A ``user``
|
|
write always lands — an explicit rename is authoritative. An automatic
|
|
write (``derived``/``llm``) lands only when the row is untitled or the
|
|
stored title has strictly lower authority, so the instant ``derived``
|
|
title upgrades to ``llm`` exactly once and neither can ever overwrite a
|
|
name the user typed. Re-running the titler on an already-``llm`` row is
|
|
a no-op, which is what stops a session renaming itself.
|
|
|
|
The read and the write are one compare-and-swap inside a single
|
|
transaction, so a manual ``/title`` racing an in-flight generation
|
|
cannot be clobbered by the late arrival.
|
|
"""
|
|
title = self.sanitize_title(title)
|
|
is_user = source == self.TITLE_SOURCE_USER
|
|
new_rank = self._title_rank(source) if not is_user else None
|
|
|
|
def _do(conn):
|
|
current = conn.execute(
|
|
"SELECT title, title_source, hidden FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if current is None:
|
|
return 0
|
|
# The canonical Bot Chat's NAME is its identity: Bot Mode resolves
|
|
# the forever-chat by exact-title lookup on every open, so renaming
|
|
# the row orphans the entire conversation — the next click mints an
|
|
# empty replacement and UNIQUE(title) then blocks ever renaming
|
|
# back (#92473). Refuse the rename at the single write path every
|
|
# surface funnels through (gateway session.title, /title, CLI
|
|
# rename, REST). Hidden is the discriminator: canonical chats are
|
|
# born hidden; an ordinary visible session a user happens to call
|
|
# "Bot Chat" stays freely renameable.
|
|
if (
|
|
is_user
|
|
and (current["title"] or "") == self.CANONICAL_BOT_CHAT_TITLE
|
|
and bool(current["hidden"])
|
|
and title != self.CANONICAL_BOT_CHAT_TITLE
|
|
):
|
|
raise ValueError(
|
|
"This is the bot's canonical Bot Chat — its name is its "
|
|
"identity, and renaming it would orphan the conversation. "
|
|
"To start fresh, create a new bot instead."
|
|
)
|
|
if not is_user and current["title"] is not None:
|
|
if self._title_rank(current["title_source"]) >= new_rank:
|
|
return 0
|
|
|
|
if title:
|
|
# Check uniqueness (allow the same session to keep its own title)
|
|
cursor = conn.execute(
|
|
"SELECT id FROM sessions WHERE title = ? AND id != ?",
|
|
(title, session_id),
|
|
)
|
|
conflict = cursor.fetchone()
|
|
if conflict:
|
|
conflict_id = conflict["id"]
|
|
# A compression continuation is the live, projected-forward
|
|
# head of its conversation; its compressed predecessors are
|
|
# ended and hidden from the session list (list_sessions_rich
|
|
# projects roots → tip). When the title that "conflicts" is
|
|
# held by such a hidden ancestor, the user has no way to free
|
|
# it — renaming the visible tip back to the base name would
|
|
# dead-end with "already in use by <session they can't see>".
|
|
# Treat this as a transfer: move the title off the ancestor
|
|
# onto the continuation. Uniqueness is preserved (still only
|
|
# one session carries the exact title) and the parent-link
|
|
# lineage is untouched.
|
|
if self._is_compression_ancestor(
|
|
conn, ancestor_id=conflict_id, descendant_id=session_id
|
|
):
|
|
conn.execute(
|
|
"UPDATE sessions SET title = NULL WHERE id = ?",
|
|
(conflict_id,),
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
f"Title '{title}' is already in use by session {conflict_id}"
|
|
)
|
|
# Compare-and-swap on the exact values we just read (``IS`` is
|
|
# NULL-safe in SQLite), so a concurrent write between the SELECT
|
|
# and here loses instead of being silently overwritten.
|
|
cursor = conn.execute(
|
|
"UPDATE sessions SET title = ?, title_source = ? "
|
|
"WHERE id = ? AND title IS ? AND title_source IS ?",
|
|
(
|
|
title,
|
|
source if title else None,
|
|
session_id,
|
|
current["title"],
|
|
current["title_source"],
|
|
),
|
|
)
|
|
return cursor.rowcount
|
|
|
|
rowcount = self._execute_write(_do)
|
|
return rowcount > 0
|
|
|
|
def set_session_title(self, session_id: str, title: str) -> bool:
|
|
"""Set or update a session's title on the user's behalf.
|
|
|
|
Returns True if session was found and title was set.
|
|
Raises ValueError if title is already in use by another session,
|
|
or if the title fails validation (too long, invalid characters).
|
|
Empty/whitespace-only strings are normalized to None (clearing the title).
|
|
|
|
This records ``user`` provenance, so auto-titling will never replace
|
|
the result. Automatic callers must use :meth:`set_auto_title`.
|
|
"""
|
|
return self._set_session_title(
|
|
session_id, title, source=self.TITLE_SOURCE_USER
|
|
)
|
|
|
|
def set_auto_title(self, session_id: str, title: str, *, source: str) -> bool:
|
|
"""Set an automatically generated title, honoring provenance precedence.
|
|
|
|
Returns True when the title was written, False when a higher-authority
|
|
title already holds the row (nothing is modified in that case).
|
|
"""
|
|
if source not in (self.TITLE_SOURCE_DERIVED, self.TITLE_SOURCE_LLM):
|
|
raise ValueError(f"invalid automatic title source: {source!r}")
|
|
return self._set_session_title(session_id, title, source=source)
|
|
|
|
def set_auto_title_if_empty(self, session_id: str, title: str) -> bool:
|
|
"""Back-compat shim: set an LLM title only if nothing better exists.
|
|
|
|
Retained because older callers (and third-party plugins) reference it
|
|
by name. New code should call :meth:`set_auto_title` with an explicit
|
|
source.
|
|
"""
|
|
return self.set_auto_title(
|
|
session_id, title, source=self.TITLE_SOURCE_LLM
|
|
)
|
|
|
|
def get_session_title(self, session_id: str) -> Optional[str]:
|
|
"""Get the title for a session, or None."""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT title FROM sessions WHERE id = ?", (session_id,)
|
|
)
|
|
row = cursor.fetchone()
|
|
return row["title"] if row else None
|
|
|
|
def get_session_title_source(self, session_id: str) -> Optional[str]:
|
|
"""Get the provenance of a session's title, or None when untitled."""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT title, title_source FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row or row["title"] is None:
|
|
return None
|
|
return row["title_source"]
|
|
|
|
def set_session_title_source(self, session_id: str, source: str) -> bool:
|
|
"""Overwrite a title's provenance without touching the title text.
|
|
|
|
Used when a title is carried across a session boundary (compression
|
|
rotation) and the copy must keep the original's authority rather than
|
|
the authority of whichever setter performed the copy.
|
|
"""
|
|
if source not in self._TITLE_SOURCE_RANK:
|
|
raise ValueError(f"invalid title source: {source!r}")
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"UPDATE sessions SET title_source = ? "
|
|
"WHERE id = ? AND title IS NOT NULL",
|
|
(source, session_id),
|
|
)
|
|
return cursor.rowcount
|
|
|
|
return self._execute_write(_do) > 0
|
|
|
|
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
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"""UPDATE sessions
|
|
SET profile_name = ?
|
|
WHERE profile_name IS NULL OR TRIM(profile_name) = ''""",
|
|
(stamp,),
|
|
)
|
|
rowcount = cursor.rowcount
|
|
if rowcount is None or rowcount < 0:
|
|
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
|
return rowcount
|
|
|
|
return int(self._execute_write(_do) or 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.
|
|
"""
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"""
|
|
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 archived = ?
|
|
WHERE id IN (SELECT id FROM lineage)
|
|
""",
|
|
(session_id, session_id, 1 if archived else 0),
|
|
)
|
|
rowcount = cursor.rowcount
|
|
if rowcount is None or rowcount < 0:
|
|
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
|
return rowcount
|
|
rowcount = self._execute_write(_do)
|
|
return rowcount > 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.
|
|
"""
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"""
|
|
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 pinned = ?
|
|
WHERE id IN (SELECT id FROM lineage)
|
|
""",
|
|
(session_id, session_id, 1 if pinned else 0),
|
|
)
|
|
rowcount = cursor.rowcount
|
|
if rowcount is None or rowcount < 0:
|
|
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
|
return rowcount
|
|
rowcount = self._execute_write(_do)
|
|
return rowcount > 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.
|
|
"""
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"""
|
|
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 hidden = ?
|
|
WHERE id IN (SELECT id FROM lineage)
|
|
""",
|
|
(session_id, session_id, 1 if hidden else 0),
|
|
)
|
|
rowcount = cursor.rowcount
|
|
if rowcount is None or rowcount < 0:
|
|
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
|
return rowcount
|
|
rowcount = self._execute_write(_do)
|
|
return rowcount > 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.
|
|
"""
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"""
|
|
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 last_read_at = ?
|
|
WHERE id IN (SELECT id FROM lineage)
|
|
""",
|
|
(session_id, session_id, time.time() if read else 0.0),
|
|
)
|
|
rowcount = cursor.rowcount
|
|
if rowcount is None or rowcount < 0:
|
|
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
|
return rowcount
|
|
rowcount = self._execute_write(_do)
|
|
return rowcount > 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)
|
|
|
|
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
|
"""Look up a session by exact title. Returns session dict or None."""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"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.title = ?",
|
|
(title,),
|
|
)
|
|
row = cursor.fetchone()
|
|
return self._session_row_dict(row) if row else None
|
|
|
|
def resolve_session_by_title(self, title: str) -> Optional[str]:
|
|
"""Resolve a title to a session ID, preferring the latest in a lineage.
|
|
|
|
If the exact title exists, returns that session's ID.
|
|
If not, searches for "title #N" variants and returns the latest one.
|
|
If the exact title exists AND numbered variants exist, returns the
|
|
latest numbered variant (the most recent continuation).
|
|
"""
|
|
# First try exact match
|
|
exact = self.get_session_by_title(title)
|
|
|
|
# Also search for numbered variants: "title #2", "title #3", etc.
|
|
# Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
|
|
escaped = _escape_like(title)
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT id, title, started_at FROM sessions "
|
|
"WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
|
|
(f"{escaped} #%",),
|
|
)
|
|
numbered = cursor.fetchall()
|
|
|
|
if numbered:
|
|
# Return the most recent numbered variant
|
|
return numbered[0]["id"]
|
|
elif exact:
|
|
return exact["id"]
|
|
return None
|
|
|
|
def get_next_title_in_lineage(self, base_title: str) -> str:
|
|
"""Generate the next title in a lineage (e.g., "my session" → "my session #2").
|
|
|
|
Strips any existing " #N" suffix to find the base name, then finds
|
|
the highest existing number and increments.
|
|
"""
|
|
# Strip existing #N suffix to find the true base
|
|
match = re.match(r'^(.*?) #(\d+)$', base_title)
|
|
if match:
|
|
base = match.group(1)
|
|
else:
|
|
base = base_title
|
|
|
|
# Find all existing numbered variants
|
|
# Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
|
|
escaped = _escape_like(base)
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
|
|
(base, f"{escaped} #%"),
|
|
)
|
|
existing = [row["title"] for row in cursor.fetchall()]
|
|
|
|
if not existing:
|
|
return base # No conflict, use the base name as-is
|
|
|
|
# Find the highest number
|
|
max_num = 1 # The unnumbered original counts as #1
|
|
for t in existing:
|
|
m = re.match(r'^.* #(\d+)$', t)
|
|
if m:
|
|
max_num = max(max_num, int(m.group(1)))
|
|
|
|
return f"{base} #{max_num + 1}"
|
|
|
|
def get_compression_tip(self, session_id: str) -> Optional[str]:
|
|
"""Walk the compression-continuation chain forward and return the tip.
|
|
|
|
A compression continuation is a child of a session whose
|
|
``end_reason = 'compression'``. Older builds tried to distinguish
|
|
continuations from branches/subagents by requiring
|
|
``child.started_at >= parent.ended_at``. That ordering is too brittle:
|
|
gateway + compression races can insert the real continuation row before
|
|
the parent row's ``ended_at`` is written, while a stale websocket later
|
|
creates/reuses a sibling that *does* satisfy the timestamp test. The
|
|
visible symptom is brutal: desktop resume follows the stale sibling and
|
|
the user's latest messages look "lost" even though they are persisted in
|
|
the real continuation chain.
|
|
|
|
Instead, only follow children of compression-ended parents, exclude
|
|
explicit branch/delegate/tool children, and prefer children that are
|
|
themselves continuing the compression chain (``end_reason='compression'``)
|
|
or still live over stale closed siblings such as ``ws_orphan_reap``.
|
|
Returns the latest continuation tip, or the input id when no
|
|
continuation exists.
|
|
"""
|
|
current = session_id
|
|
seen = {current} if current else set()
|
|
# Bound the walk defensively — compression chains this deep are
|
|
# pathological and shouldn't happen in practice. 100 = plenty.
|
|
for _ in range(100):
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"""
|
|
SELECT child.id
|
|
FROM sessions parent
|
|
JOIN sessions child ON child.parent_session_id = parent.id
|
|
WHERE parent.id = ?
|
|
AND 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'
|
|
ORDER BY
|
|
CASE
|
|
WHEN child.end_reason = 'compression' THEN 0
|
|
WHEN child.ended_at IS NULL THEN 1
|
|
ELSE 2
|
|
END,
|
|
{_sql_session_last_active("child")} DESC,
|
|
child.started_at DESC,
|
|
child.id DESC
|
|
LIMIT 1
|
|
""",
|
|
(current,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row is None:
|
|
return current
|
|
child_id = row["id"]
|
|
if not child_id or child_id in seen:
|
|
return current
|
|
seen.add(child_id)
|
|
current = child_id
|
|
return current
|
|
|
|
# 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 usage_totals(self, *, min_message_count: int = 1, include_archived: bool = False) -> Dict[str, float]:
|
|
"""Tokens and spend across this store, as one aggregate.
|
|
|
|
The sidebar shows a profile's totals beside a page of its sessions, so
|
|
summing the rows it happens to have loaded would report a fraction of
|
|
the truth and shrink as paging changed. SQLite adds the columns up over
|
|
every row instead, at the cost of one scan.
|
|
|
|
Spend is the billed figure when the provider returned one and the
|
|
estimate otherwise — the same precedence a single row renders.
|
|
"""
|
|
where = ["parent_session_id IS NULL", "message_count >= ?"]
|
|
params: List[Any] = [min_message_count]
|
|
if not include_archived:
|
|
where.append("COALESCE(archived, 0) = 0")
|
|
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0),
|
|
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0)
|
|
FROM sessions
|
|
WHERE {' AND '.join(where)}
|
|
""",
|
|
params,
|
|
).fetchone()
|
|
|
|
return {"tokens": int(row[0] or 0), "cost_usd": float(row[1] or 0.0)}
|
|
|
|
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 timestamp.
|
|
|
|
Returns dicts with keys: id, source, model, title, started_at, ended_at,
|
|
message_count, preview (first 60 chars of first user message),
|
|
last_active (freshest of last_activity_at heartbeat and latest
|
|
message timestamp, else started_at).
|
|
|
|
Uses a single query with correlated subqueries instead of N+2 queries.
|
|
|
|
By default, child sessions that represent implementation details
|
|
(subagent runs, compression continuations) are excluded. User-visible
|
|
branch and reset children remain listable. Pass ``include_children=True``
|
|
to include every child.
|
|
|
|
With ``project_compression_tips=True`` (default), sessions that are
|
|
roots of compression chains are projected forward to their latest
|
|
continuation — one logical conversation = one list entry, showing the
|
|
live continuation's id/message_count/title/last_active. This prevents
|
|
compressed continuations from being invisible to users while keeping
|
|
delegate subagents and branches hidden. Pass ``False`` to return the
|
|
raw root rows (useful for admin/debug UIs).
|
|
|
|
Pass ``order_by_last_active=True`` to sort by most-recent activity
|
|
instead of original conversation start time. For compression chains,
|
|
the "most-recent activity" is taken from the live tip (not the root),
|
|
so an old conversation that was compressed and continued recently
|
|
surfaces in the correct slot. Ordering is computed at SQL level via
|
|
a recursive CTE that walks compression-continuation edges, so LIMIT
|
|
and OFFSET still apply efficiently.
|
|
|
|
``search_query`` matches case-insensitive substrings against each
|
|
surfaced row's title and id (and, like ``id_query``, every title/id in
|
|
its forward compression chain). A punctuation-stripped variant is also
|
|
matched so e.g. ``an94`` finds ``AN-94``. Only honored in the
|
|
``order_by_last_active`` path.
|
|
|
|
Pass ``compact_rows=True`` for dashboard and picker callers that only
|
|
need lightweight metadata. This omits the ``system_prompt`` blob from
|
|
the SELECT so SQLite never copies it out of the B-tree page — a
|
|
significant I/O saving on large databases where the blob routinely
|
|
runs to tens of kilobytes per row.
|
|
|
|
Pass ``include_pinned=True`` to back-fill any conversation carrying the
|
|
durable ``pinned`` flag that the LIMIT/OFFSET window left out. A pin is
|
|
a "this must always be reachable" statement, so a pinned conversation
|
|
aging past the requested page is a bug, not a paging outcome — the
|
|
desktop sidebar would render an empty Pinned section. Back-filled rows
|
|
obey the same filters (source, archived, min_message_count) as the
|
|
page: an archived or filtered-out conversation stays out.
|
|
|
|
Pass ``session_key`` to restrict results to one stable gateway
|
|
conversation scope (DM, group, channel, or thread, including the
|
|
configured per-user isolation policy).
|
|
"""
|
|
# 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])
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(query, params)
|
|
rows = cursor.fetchall()
|
|
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
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
pinned_cursor = conn.execute(pinned_query, base_where_params)
|
|
pinned_rows = pinned_cursor.fetchall()
|
|
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] = {}
|
|
for s in sessions:
|
|
if s.get("end_reason") != "compression":
|
|
continue
|
|
tip_id = self.get_compression_tip(s["id"])
|
|
if tip_id != s["id"]:
|
|
tip_ids_by_root[s["id"]] = tip_id
|
|
|
|
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"]
|
|
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
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(query, ids).fetchall()
|
|
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:"
|
|
|
|
@classmethod
|
|
def _encode_content(cls, content: Any) -> Any:
|
|
"""Serialize structured (list/dict) message content for sqlite.
|
|
|
|
sqlite3 can only bind ``str``, ``bytes``, ``int``, ``float``, and ``None``
|
|
to query parameters. Multimodal messages have ``content`` as a list of
|
|
parts (``[{"type": "text", ...}, {"type": "image_url", ...}]``), which
|
|
raises ``ProgrammingError: Error binding parameter N: type 'list' is
|
|
not supported`` when bound directly.
|
|
|
|
Returns the value unchanged when it's already a safe scalar, or a
|
|
sentinel-prefixed JSON string for lists/dicts. Paired with
|
|
:meth:`_decode_content` on read.
|
|
"""
|
|
if isinstance(content, str):
|
|
# Lone UTF-16 surrogates reach here inside tool results scraped
|
|
# from the web/social platforms (the same input that crashed the
|
|
# guardrail hasher). The proactive sanitizer upstream only cleans
|
|
# the *api_messages* copy, and the recovery sanitizer only runs
|
|
# after the API call itself raises — which it no longer does — so
|
|
# the canonical history keeps them and this write is where they
|
|
# land. Left raw, sqlite3 raises UnicodeEncodeError, the flush is
|
|
# abandoned, and the session silently stops persisting for the
|
|
# rest of its life. Scrub so persistence never fails.
|
|
return _sanitize_surrogates(content)
|
|
if content is None or isinstance(content, (bytes, int, float)):
|
|
return content
|
|
try:
|
|
# json.dumps defaults to ensure_ascii=True, which escapes any
|
|
# surrogate as \udXXX — already safe to bind.
|
|
return cls._CONTENT_JSON_PREFIX + json.dumps(content)
|
|
except (TypeError, ValueError):
|
|
# Last-resort fallback: stringify so persistence never fails.
|
|
return _sanitize_surrogates(str(content))
|
|
|
|
@classmethod
|
|
def _decode_content(cls, content: Any) -> Any:
|
|
"""Reverse :meth:`_encode_content`; returns scalars unchanged."""
|
|
if isinstance(content, str) and content.startswith(cls._CONTENT_JSON_PREFIX):
|
|
try:
|
|
return json.loads(content[len(cls._CONTENT_JSON_PREFIX):])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning(
|
|
"Failed to decode JSON-encoded message content; "
|
|
"returning raw string"
|
|
)
|
|
return content
|
|
return content
|
|
|
|
@staticmethod
|
|
def _encode_display_metadata(display_metadata: Any) -> Optional[str]:
|
|
"""Serialize ``display_metadata`` for its TEXT column without double-encoding.
|
|
|
|
Import/replace paths can hand us an already-serialized JSON string (the
|
|
same hazard ``tool_calls`` guards against above). ``json.dumps`` on that
|
|
string would store a quoted JSON string, and the single ``json.loads``
|
|
on read then yields a ``str`` instead of a dict.
|
|
"""
|
|
if not display_metadata:
|
|
return None
|
|
if isinstance(display_metadata, str):
|
|
try:
|
|
parsed = json.loads(display_metadata)
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Ignoring non-JSON display metadata on write")
|
|
return None
|
|
if not isinstance(parsed, dict):
|
|
logger.warning("Ignoring non-object display metadata on write")
|
|
return None
|
|
return json.dumps(parsed)
|
|
if isinstance(display_metadata, dict):
|
|
return json.dumps(display_metadata)
|
|
logger.warning(
|
|
"Ignoring unexpected display metadata type on write: %s",
|
|
type(display_metadata).__name__,
|
|
)
|
|
return None
|
|
|
|
def _check_transcript_write_guards(
|
|
self,
|
|
conn,
|
|
session_id: str,
|
|
compression_lock_holder: Optional[str],
|
|
turn_lease_holder: Optional[str] = None,
|
|
turn_lease_ttl_seconds: float = 300.0,
|
|
reject_active_turn_lease: bool = False,
|
|
reject_active_compression_lock: bool = False,
|
|
) -> None:
|
|
"""Transcript-write admission checks, run INSIDE the write txn.
|
|
|
|
Shared by :meth:`append_message` and :meth:`append_messages_batch` so
|
|
the two writers can never diverge on these correctness invariants
|
|
(this guard has already needed targeted fixes — see the #74478 patience
|
|
note below). User-initiated transcript mutations may opt in to rejecting
|
|
an active unowned turn lease in that same transaction.
|
|
"""
|
|
# NOTE (#75316 redesign): appends do NOT check compression_locks.
|
|
# The lock's job is to stop two COMPRESSIONS colliding, not to fence
|
|
# ordinary transcript writes. Concurrent appends during a compression
|
|
# are safe by construction: archive_and_compact() commits against a
|
|
# watermark captured at compression start and clones every row that
|
|
# arrived after it back into the live transcript, in the same write
|
|
# transaction. Blocking appends here was the root cause of a whole
|
|
# symptom family — turns dying as session_persistence_failed while a
|
|
# slow provider summary held the lease (#74568, #77386), including
|
|
# stale locks from dead PIDs blocking writes for the full TTL.
|
|
# Destructive user mutations are different: a compressor that already
|
|
# captured its watermark can otherwise publish the pre-rewind snapshot
|
|
# after the mutation and resurrect the removed turn. Keep that narrow
|
|
# fence opt-in so ordinary appends retain the watermark behavior.
|
|
if reject_active_compression_lock:
|
|
active_lock = conn.execute(
|
|
"SELECT holder, expires_at FROM compression_locks "
|
|
"WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if active_lock is not None:
|
|
current_holder = active_lock["holder"]
|
|
if (
|
|
float(active_lock["expires_at"]) <= time.time()
|
|
or _compression_lock_holder_process_is_dead(current_holder)
|
|
):
|
|
conn.execute(
|
|
"DELETE FROM compression_locks "
|
|
"WHERE session_id = ? AND holder = ?",
|
|
(session_id, current_holder),
|
|
)
|
|
elif current_holder != compression_lock_holder:
|
|
raise SessionCompressionInProgressError(
|
|
f"Session {session_id!r} is being compressed by another writer"
|
|
)
|
|
if turn_lease_holder or reject_active_turn_lease:
|
|
conversation_id = self._session_turn_lease_key_on_conn(conn, session_id)
|
|
lease = conn.execute(
|
|
"SELECT holder, expires_at FROM session_turn_leases "
|
|
"WHERE conversation_id = ?",
|
|
(conversation_id,),
|
|
).fetchone()
|
|
now = time.time()
|
|
if turn_lease_holder:
|
|
if lease is None or lease["holder"] != turn_lease_holder:
|
|
raise SessionTurnLeaseLostError(
|
|
f"Session turn lease lost; refusing transcript write "
|
|
f"for {session_id!r}"
|
|
)
|
|
if float(lease["expires_at"]) <= now:
|
|
# Expiry makes the row reclaimable; it does not prove that a
|
|
# takeover occurred. BEGIN IMMEDIATE serializes this renewal
|
|
# with acquisition, so a still-matching owner can recover from
|
|
# a starved refresher without weakening the foreign-holder fence.
|
|
conn.execute(
|
|
"UPDATE session_turn_leases SET expires_at = ? "
|
|
"WHERE conversation_id = ? AND holder = ?",
|
|
(
|
|
now + max(0.1, float(turn_lease_ttl_seconds)),
|
|
conversation_id,
|
|
turn_lease_holder,
|
|
),
|
|
)
|
|
elif lease is not None:
|
|
current_holder = lease["holder"]
|
|
if (
|
|
float(lease["expires_at"]) <= now
|
|
or _compression_lock_holder_process_is_dead(current_holder)
|
|
):
|
|
# Match acquisition semantics: an expired or provably dead
|
|
# owner is reclaimable. Deleting it inside this BEGIN IMMEDIATE
|
|
# transaction also fences a stale late flush after the mutation.
|
|
conn.execute(
|
|
"DELETE FROM session_turn_leases "
|
|
"WHERE conversation_id = ? AND holder = ?",
|
|
(conversation_id, current_holder),
|
|
)
|
|
else:
|
|
raise SessionTurnLeaseLostError(
|
|
f"Session has an active turn lease; refusing transcript "
|
|
f"mutation for {session_id!r}"
|
|
)
|
|
session = conn.execute(
|
|
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if (
|
|
session is not None
|
|
and session["ended_at"] is not None
|
|
and session["end_reason"] == "compression"
|
|
):
|
|
raise CompressionSessionClosedError(session_id)
|
|
|
|
@staticmethod
|
|
def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]:
|
|
"""Decode a ``display_metadata`` column into the dict every reader expects.
|
|
|
|
Every message read path must go through this. Returning the raw TEXT
|
|
instead reaches the desktop as a string, where ``'task_count' in meta``
|
|
throws and fails the whole resume. Rows written before the encode guard
|
|
landed are double-encoded, so unwrap a second layer when we find one.
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
meta = json.loads(raw) if isinstance(raw, str) else raw
|
|
if isinstance(meta, str):
|
|
meta = json.loads(meta)
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Ignoring invalid display metadata on message row")
|
|
return None
|
|
if not isinstance(meta, dict):
|
|
logger.warning("Ignoring non-object display metadata on message row")
|
|
return None
|
|
return meta
|
|
|
|
@staticmethod
|
|
def _reasoning_json_text(value: Any) -> Optional[str]:
|
|
"""Serialize a structured reasoning field for its TEXT column.
|
|
|
|
``reasoning_details`` / ``codex_reasoning_items`` / ``codex_message_items``
|
|
arrive as list/dict structures from the live runtime, but callers that
|
|
round-trip stored rows — ``get_messages`` straight into
|
|
``replace_messages``, e.g. the POST /api/sessions/{id}/fork handler —
|
|
hand back the raw TEXT these columns already hold, because
|
|
``get_messages`` only deserializes ``content`` and ``tool_calls``.
|
|
Re-dumping that TEXT double-encodes it, and the forked session's next
|
|
``get_messages_as_conversation`` json.loads then yields the inner
|
|
string instead of the original list, so every reasoning-replay consumer
|
|
(all of which check ``isinstance(..., list)``) silently drops it.
|
|
Strings are therefore stored as-is; structures are dumped.
|
|
"""
|
|
if not value:
|
|
return None
|
|
if isinstance(value, str):
|
|
return value
|
|
return json.dumps(value)
|
|
|
|
def append_message(
|
|
self,
|
|
session_id: str,
|
|
role: str,
|
|
content: str = None,
|
|
tool_name: str = None,
|
|
tool_calls: Any = None,
|
|
tool_call_id: str = None,
|
|
token_count: int = None,
|
|
finish_reason: str = None,
|
|
reasoning: str = None,
|
|
reasoning_content: str = None,
|
|
reasoning_details: Any = None,
|
|
codex_reasoning_items: Any = None,
|
|
codex_message_items: Any = None,
|
|
platform_message_id: str = None,
|
|
observed: bool = False,
|
|
effect_disposition: Optional[str] = None,
|
|
_compressed_summary: bool = False,
|
|
timestamp: Any = None,
|
|
api_content: Optional[str] = None,
|
|
display_kind: Optional[str] = None,
|
|
display_metadata: Optional[Dict[str, Any]] = None,
|
|
compression_lock_holder: Optional[str] = None,
|
|
turn_lease_holder: Optional[str] = None,
|
|
turn_lease_ttl_seconds: float = 300.0,
|
|
) -> int:
|
|
"""
|
|
Append a message to a session. Returns the message row ID.
|
|
|
|
Also increments the session's message_count (and tool_call_count
|
|
if role is 'tool' or tool_calls is present).
|
|
|
|
``platform_message_id`` is the external messaging platform's own
|
|
message ID (e.g. Telegram update_id, Yuanbao msg_id). It is
|
|
independent of the SQLite autoincrement primary key and is used by
|
|
platform-specific flows like yuanbao's recall guard to redact a
|
|
message by its platform-side identifier.
|
|
|
|
``api_content`` is the exact content string sent to the API for this
|
|
message when it differs from ``content`` (ephemeral memory/plugin
|
|
injections, persist overrides). It is a byte-fidelity sidecar for
|
|
prompt-cache-stable replay — stored as sent, except lone surrogates
|
|
(which sqlite3 cannot bind and which the conversation loop scrubs
|
|
from every outgoing payload anyway, so the scrubbed form IS the
|
|
wire bytes).
|
|
"""
|
|
# Display metadata is presentation-only and never changes the model
|
|
# context role/content replayed to providers.
|
|
display_metadata_json = self._encode_display_metadata(display_metadata)
|
|
# Serialize structured fields to JSON before entering the write txn
|
|
reasoning_details_json = self._reasoning_json_text(reasoning_details)
|
|
codex_items_json = self._reasoning_json_text(codex_reasoning_items)
|
|
codex_message_items_json = self._reasoning_json_text(codex_message_items)
|
|
# tool_calls may arrive as a Python list (from the live agent) or
|
|
# as a JSON string (from import/export). Parse first to avoid
|
|
# double-encoding.
|
|
if isinstance(tool_calls, str):
|
|
try:
|
|
tool_calls = json.loads(tool_calls)
|
|
except (json.JSONDecodeError, TypeError):
|
|
tool_calls = []
|
|
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
|
|
# Multimodal content (list of parts) must be JSON-encoded: sqlite3
|
|
# cannot bind list/dict parameters directly.
|
|
stored_content = self._encode_content(content)
|
|
|
|
message_timestamp = time.time()
|
|
if timestamp is not None:
|
|
try:
|
|
if hasattr(timestamp, "timestamp"):
|
|
message_timestamp = float(timestamp.timestamp())
|
|
else:
|
|
message_timestamp = float(timestamp)
|
|
except (TypeError, ValueError):
|
|
logger.debug("Ignoring invalid explicit message timestamp: %r", timestamp)
|
|
|
|
# Pre-compute tool call count
|
|
num_tool_calls = 0
|
|
if tool_calls is not None:
|
|
num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1
|
|
|
|
def _do(conn):
|
|
self._check_transcript_write_guards(
|
|
conn,
|
|
session_id,
|
|
compression_lock_holder,
|
|
turn_lease_holder=turn_lease_holder,
|
|
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
|
|
)
|
|
cursor = conn.execute(
|
|
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
|
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
|
|
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
|
|
codex_message_items, platform_message_id, observed, _compressed_summary, active, api_content, display_kind, display_metadata)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
session_id,
|
|
role,
|
|
stored_content,
|
|
tool_call_id,
|
|
tool_calls_json,
|
|
_scrub_surrogates(tool_name),
|
|
effect_disposition,
|
|
message_timestamp,
|
|
token_count,
|
|
finish_reason,
|
|
_scrub_surrogates(reasoning),
|
|
_scrub_surrogates(reasoning_content),
|
|
reasoning_details_json,
|
|
codex_items_json,
|
|
codex_message_items_json,
|
|
platform_message_id,
|
|
1 if observed else 0,
|
|
1 if _compressed_summary else 0,
|
|
1,
|
|
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
|
|
_scrub_surrogates(display_kind) if isinstance(display_kind, str) else None,
|
|
display_metadata_json,
|
|
),
|
|
)
|
|
msg_id = cursor.lastrowid
|
|
|
|
# Update counters
|
|
if num_tool_calls > 0:
|
|
conn.execute(
|
|
"""UPDATE sessions SET message_count = message_count + 1,
|
|
tool_call_count = tool_call_count + ? WHERE id = ?""",
|
|
(num_tool_calls, session_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
return msg_id
|
|
|
|
# Transcript append is THE critical write: its failure aborts the
|
|
# user's turn (session_persistence_failed). Use the long patience so
|
|
# a sibling process legitimately holding the write lock for seconds
|
|
# (VACUUM, TRUNCATE checkpoint at close, an older pre-bounded-merge
|
|
# process's FTS optimize) can't destroy a healthy turn (#74478).
|
|
return self._execute_write(
|
|
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
|
|
)
|
|
|
|
def append_messages_batch(
|
|
self,
|
|
session_id: str,
|
|
messages: List[Dict[str, Any]],
|
|
compression_lock_holder: Optional[str] = None,
|
|
turn_lease_holder: Optional[str] = None,
|
|
chunk_rows: Optional[int] = None,
|
|
turn_lease_ttl_seconds: float = 300.0,
|
|
) -> int:
|
|
"""Append multiple messages atomically in ONE write transaction.
|
|
|
|
``messages`` is a list of dicts in the same shape
|
|
:meth:`_insert_message_rows` already consumes for replace/compact/
|
|
import (role, content, tool_name, tool_calls, tool_call_id,
|
|
finish_reason, reasoning*, codex_*, timestamp, api_content,
|
|
display_kind, display_metadata, ...). Reusing that helper keeps ONE
|
|
row-serialization path for every multi-row writer.
|
|
|
|
A turn-boundary flush writes the whole turn (user + assistant + tool
|
|
rows, typically 3-8 messages) as one BEGIN IMMEDIATE / commit pair
|
|
instead of one transaction (and, off WAL, one fsync) per row.
|
|
|
|
Atomicity contract: all rows land or none do (the caller re-flushes
|
|
unstamped messages on the next attempt). The same admission guards
|
|
as :meth:`append_message` run once for the batch — same session,
|
|
same instant.
|
|
|
|
``chunk_rows`` bounds the transaction size for LARGE copies (branch
|
|
seeds can be thousands of rows; measured: 10k rows ≈ 2.4s inside one
|
|
BEGIN IMMEDIATE because the FTS triggers run per row, which would
|
|
monopolize the write lock and starve concurrent writers). When set,
|
|
the batch commits in chunks of at most that many rows — same
|
|
recovery semantics as the old per-row loops (a mid-copy failure
|
|
leaves a partial seed), just with bounded lock holds. A turn flush
|
|
never needs it. Returns the inserted row count.
|
|
"""
|
|
if not messages:
|
|
return 0
|
|
|
|
if chunk_rows is not None and len(messages) > chunk_rows:
|
|
inserted_total = 0
|
|
for start in range(0, len(messages), chunk_rows):
|
|
inserted_total += self.append_messages_batch(
|
|
session_id,
|
|
messages[start:start + chunk_rows],
|
|
compression_lock_holder=compression_lock_holder,
|
|
turn_lease_holder=turn_lease_holder,
|
|
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
|
|
)
|
|
return inserted_total
|
|
|
|
def _do(conn):
|
|
self._check_transcript_write_guards(
|
|
conn,
|
|
session_id,
|
|
compression_lock_holder,
|
|
turn_lease_holder=turn_lease_holder,
|
|
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
|
|
)
|
|
from agent.transcript_repair import resolve_and_repair_transcript_batch
|
|
|
|
inserted_rows = resolve_and_repair_transcript_batch(
|
|
conn,
|
|
session_id,
|
|
messages,
|
|
encode_content_fn=self._encode_content,
|
|
decode_content_fn=self._decode_content,
|
|
)
|
|
inserted = 0
|
|
tool_calls_total = 0
|
|
if inserted_rows:
|
|
inserted, tool_calls_total = self._insert_message_rows(
|
|
conn, session_id, inserted_rows
|
|
)
|
|
|
|
# One aggregated counter update for the newly inserted rows.
|
|
if tool_calls_total > 0:
|
|
conn.execute(
|
|
"""UPDATE sessions SET message_count = message_count + ?,
|
|
tool_call_count = tool_call_count + ? WHERE id = ?""",
|
|
(inserted, tool_calls_total, session_id),
|
|
)
|
|
elif inserted > 0:
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = message_count + ? WHERE id = ?",
|
|
(inserted, session_id),
|
|
)
|
|
return inserted
|
|
|
|
# Same criticality as append_message: this IS the turn's transcript.
|
|
return self._execute_write(
|
|
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
|
|
)
|
|
|
|
def set_latest_matching_message_display_kind(
|
|
self, session_id: str, *, role: str, content: str, display_kind: str,
|
|
display_metadata: Optional[Dict[str, Any]] = None,
|
|
) -> bool:
|
|
"""Stamp presentation metadata on this turn's freshly persisted row.
|
|
|
|
The model still receives ``role`` and ``content`` unchanged. Gateway and
|
|
CLI synthetic inputs call this immediately after their serial turn has
|
|
flushed, preserving producer provenance without classifying by content
|
|
during transcript rendering.
|
|
"""
|
|
if not session_id or not content or not display_kind:
|
|
return False
|
|
|
|
def _do(conn):
|
|
row = conn.execute(
|
|
"SELECT id FROM messages WHERE session_id = ? AND role = ? "
|
|
"AND content = ? AND active = 1 ORDER BY id DESC LIMIT 1",
|
|
(session_id, role, self._encode_content(content)),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
conn.execute(
|
|
"UPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?",
|
|
(
|
|
_scrub_surrogates(display_kind),
|
|
self._encode_display_metadata(display_metadata),
|
|
row[0],
|
|
),
|
|
)
|
|
return True
|
|
|
|
return bool(self._execute_write(_do))
|
|
|
|
#: 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"
|
|
|
|
def set_message_reaction(
|
|
self,
|
|
session_id: str,
|
|
message_row_id: int,
|
|
emoji: Optional[str],
|
|
*,
|
|
author: str = "user",
|
|
) -> Optional[List[Dict[str, Any]]]:
|
|
"""Set (or with ``emoji=None`` clear) *author*'s reaction on one message.
|
|
|
|
iOS Tapback semantics: one reaction per author per message. Re-sending
|
|
the same emoji clears it, a different emoji replaces it. Returns the
|
|
message's full reaction list after the write, or ``None`` when the row
|
|
doesn't exist or isn't part of *session_id*.
|
|
"""
|
|
if not session_id or message_row_id is None:
|
|
return None
|
|
|
|
def _do(conn):
|
|
row = conn.execute(
|
|
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
|
|
(message_row_id, session_id),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
|
|
meta = self._decode_display_metadata(row[0]) or {}
|
|
existing = meta.get(self.REACTIONS_METADATA_KEY)
|
|
reactions = [
|
|
r
|
|
for r in (existing if isinstance(existing, list) else [])
|
|
if isinstance(r, dict) and r.get("author") != author
|
|
]
|
|
previous = next(
|
|
(
|
|
r
|
|
for r in (existing if isinstance(existing, list) else [])
|
|
if isinstance(r, dict) and r.get("author") == author
|
|
),
|
|
None,
|
|
)
|
|
# Tapping the live reaction again retracts it.
|
|
toggling_off = (
|
|
emoji is not None and previous is not None and previous.get("emoji") == emoji
|
|
)
|
|
if emoji and not toggling_off:
|
|
reactions.append(
|
|
{"emoji": _scrub_surrogates(emoji), "author": author, "at": time.time()}
|
|
)
|
|
|
|
if reactions:
|
|
meta[self.REACTIONS_METADATA_KEY] = reactions
|
|
else:
|
|
meta.pop(self.REACTIONS_METADATA_KEY, None)
|
|
|
|
conn.execute(
|
|
"UPDATE messages SET display_metadata = ? WHERE id = ?",
|
|
(self._encode_display_metadata(meta) if meta else None, message_row_id),
|
|
)
|
|
return reactions
|
|
|
|
return self._execute_write(_do)
|
|
|
|
def get_message_reactions(
|
|
self, session_id: str, message_row_id: int
|
|
) -> List[Dict[str, Any]]:
|
|
"""Return the reaction list persisted on one message row (never ``None``)."""
|
|
if not session_id or message_row_id is None:
|
|
return []
|
|
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
|
|
(message_row_id, session_id),
|
|
).fetchone()
|
|
|
|
if row is None:
|
|
return []
|
|
|
|
meta = self._decode_display_metadata(row[0]) or {}
|
|
reactions = meta.get(self.REACTIONS_METADATA_KEY)
|
|
|
|
return [r for r in reactions if isinstance(r, dict)] if isinstance(reactions, list) else []
|
|
|
|
def take_unseen_reactions(
|
|
self, session_id: str, *, author: str = "user"
|
|
) -> List[Dict[str, Any]]:
|
|
"""Return *author*'s not-yet-surfaced reactions and mark them seen.
|
|
|
|
Powers the cache-safe model-context path: reactions are announced on the
|
|
NEXT user turn (never by rewriting the message that was reacted to), and
|
|
the ``seen`` stamp guarantees each one is announced exactly once.
|
|
"""
|
|
if not session_id:
|
|
return []
|
|
|
|
def _do(conn):
|
|
rows = conn.execute(
|
|
"SELECT id, role, content, display_metadata FROM messages "
|
|
"WHERE session_id = ? AND active = 1 AND display_metadata IS NOT NULL "
|
|
"ORDER BY id",
|
|
(session_id,),
|
|
).fetchall()
|
|
|
|
pending = []
|
|
for row in rows:
|
|
meta = self._decode_display_metadata(row["display_metadata"])
|
|
if not meta:
|
|
continue
|
|
reactions = meta.get(self.REACTIONS_METADATA_KEY)
|
|
if not isinstance(reactions, list):
|
|
continue
|
|
|
|
changed = False
|
|
for reaction in reactions:
|
|
if (
|
|
not isinstance(reaction, dict)
|
|
or reaction.get("author") != author
|
|
or reaction.get("seen")
|
|
):
|
|
continue
|
|
reaction["seen"] = True
|
|
changed = True
|
|
content = self._decode_content(row["content"])
|
|
pending.append(
|
|
{
|
|
"row_id": row["id"],
|
|
"role": row["role"],
|
|
"emoji": reaction.get("emoji") or "",
|
|
"text": content if isinstance(content, str) else "",
|
|
}
|
|
)
|
|
|
|
if changed:
|
|
conn.execute(
|
|
"UPDATE messages SET display_metadata = ? WHERE id = ?",
|
|
(self._encode_display_metadata(meta), row["id"]),
|
|
)
|
|
|
|
return pending
|
|
|
|
return self._execute_write(_do) or []
|
|
|
|
def latest_message_row_id(
|
|
self, session_id: str, *, role: str = "user", offset: int = 0, require_text: bool = True
|
|
) -> Optional[int]:
|
|
"""Row id of the most recent active message with *role*, or ``None``.
|
|
|
|
Two callers, same need — "the message I mean, without an id": the agent
|
|
defaulting to the turn that triggered it, and the desktop reacting to a
|
|
live message that hasn't round-tripped through a resume yet.
|
|
``offset`` steps to earlier turns (1 = the one before the latest) so a
|
|
reaction can land retroactively — "two messages ago" is how the caller
|
|
thinks about it.
|
|
|
|
``require_text`` (default) skips rows with no plain-text content —
|
|
tool-call-only assistant turns and attachment stubs don't render as
|
|
bubbles, so "the latest message" as a HUMAN means it must never
|
|
resolve to one (a reaction landing on an invisible row looks dropped,
|
|
and its annotation quotes an empty string).
|
|
"""
|
|
if not session_id or role not in {"user", "assistant"} or offset < 0:
|
|
return None
|
|
|
|
text_filter = (
|
|
"AND content IS NOT NULL AND TRIM(content) != '' " if require_text else ""
|
|
)
|
|
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM messages WHERE session_id = ? AND role = ? "
|
|
f"AND active = 1 {text_filter}ORDER BY id DESC LIMIT 1 OFFSET ?",
|
|
(session_id, role, int(offset)),
|
|
).fetchone()
|
|
|
|
return row[0] if row else None
|
|
|
|
def latest_user_message_row_id(self, session_id: str) -> Optional[int]:
|
|
"""Row id of the most recent active user message, or ``None``.
|
|
|
|
The agent's default reaction target: "the message that triggered me",
|
|
so the model never has to thread row ids through a tool call (mirrors
|
|
the photon adapter's ``_record_last_inbound``).
|
|
"""
|
|
return self.latest_message_row_id(session_id, role="user")
|
|
|
|
def get_message_role(self, session_id: str, row_id: int) -> Optional[str]:
|
|
"""Role of the active message at *row_id* in *session_id*, or ``None``.
|
|
|
|
Lets a reaction event carry the target's role so a renderer can match
|
|
a live message that doesn't know its durable row id yet.
|
|
"""
|
|
if not session_id:
|
|
return None
|
|
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT role FROM messages WHERE id = ? AND session_id = ? AND active = 1",
|
|
(int(row_id), session_id),
|
|
).fetchone()
|
|
|
|
return row[0] if row else None
|
|
|
|
def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, Any]]) -> tuple[int, int]:
|
|
"""Insert *messages* as fresh active rows for *session_id*.
|
|
|
|
Shared by :meth:`replace_messages` (delete-then-insert) and
|
|
:meth:`archive_and_compact` (soft-archive-then-insert). Runs inside the
|
|
caller's write transaction (takes the live ``conn``). Returns
|
|
``(inserted_count, tool_call_count)``. Does NOT touch sessions.* counters
|
|
— the caller owns that, since the two flows reconcile counts differently.
|
|
"""
|
|
now_ts = time.time()
|
|
inserted = 0
|
|
tool_calls_total = 0
|
|
for msg in messages:
|
|
role = msg.get("role", "unknown")
|
|
tool_calls = msg.get("tool_calls")
|
|
message_timestamp = now_ts
|
|
if msg.get("timestamp") is not None:
|
|
try:
|
|
ts_value = msg.get("timestamp")
|
|
if hasattr(ts_value, "timestamp"):
|
|
message_timestamp = float(ts_value.timestamp())
|
|
else:
|
|
message_timestamp = float(ts_value)
|
|
except (TypeError, ValueError):
|
|
logger.debug("Ignoring invalid explicit message timestamp: %r", msg.get("timestamp"))
|
|
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
|
|
codex_reasoning_items = (
|
|
msg.get("codex_reasoning_items") if role == "assistant" else None
|
|
)
|
|
codex_message_items = (
|
|
msg.get("codex_message_items") if role == "assistant" else None
|
|
)
|
|
reasoning_details_json = self._reasoning_json_text(reasoning_details)
|
|
codex_items_json = self._reasoning_json_text(codex_reasoning_items)
|
|
codex_message_items_json = self._reasoning_json_text(codex_message_items)
|
|
# tool_calls may arrive as a Python list (from the live agent)
|
|
# or as a JSON string (from import_sessions / export_session,
|
|
# which store it as TEXT). json.dumps on an already-serialized
|
|
# string double-encodes it, so parse first.
|
|
if isinstance(tool_calls, str):
|
|
try:
|
|
tool_calls = json.loads(tool_calls)
|
|
except (json.JSONDecodeError, TypeError):
|
|
tool_calls = []
|
|
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
|
|
# Accept either `platform_message_id` (new explicit name) or
|
|
# `message_id` (yuanbao's existing convention on message dicts).
|
|
platform_msg_id = (
|
|
msg.get("platform_message_id") or msg.get("message_id")
|
|
)
|
|
|
|
api_content = msg.get("api_content")
|
|
|
|
cur = conn.execute(
|
|
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
|
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
|
|
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
|
|
codex_message_items, platform_message_id, observed, _compressed_summary, active, api_content, display_kind, display_metadata)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
session_id,
|
|
role,
|
|
self._encode_content(msg.get("content")),
|
|
msg.get("tool_call_id"),
|
|
tool_calls_json,
|
|
_scrub_surrogates(msg.get("tool_name")),
|
|
msg.get("effect_disposition"),
|
|
message_timestamp,
|
|
msg.get("token_count"),
|
|
msg.get("finish_reason"),
|
|
_scrub_surrogates(msg.get("reasoning")) if role == "assistant" else None,
|
|
_scrub_surrogates(msg.get("reasoning_content")) if role == "assistant" else None,
|
|
reasoning_details_json,
|
|
codex_items_json,
|
|
codex_message_items_json,
|
|
platform_msg_id,
|
|
1 if msg.get("observed") else 0,
|
|
1 if msg.get("_compressed_summary") else 0,
|
|
1,
|
|
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
|
|
_scrub_surrogates(msg.get("display_kind")) if isinstance(msg.get("display_kind"), str) else None,
|
|
self._encode_display_metadata(msg.get("display_metadata")),
|
|
),
|
|
)
|
|
if isinstance(msg, dict) and cur.lastrowid is not None:
|
|
msg["_row_id"] = cur.lastrowid
|
|
inserted += 1
|
|
if tool_calls is not None:
|
|
tool_calls_total += (
|
|
len(tool_calls) if isinstance(tool_calls, list) else 1
|
|
)
|
|
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
|
|
return inserted, tool_calls_total
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id: str,
|
|
messages: List[Dict[str, Any]],
|
|
active_only: bool = False,
|
|
archive_dropped: bool = False,
|
|
reject_active_turn_lease: bool = False,
|
|
) -> None:
|
|
"""Atomically replace the stored messages for a session.
|
|
|
|
Used by transcript-rewrite flows such as /retry, /undo, and /compress.
|
|
The delete + reinsert sequence must commit as one transaction so a
|
|
mid-rewrite failure does not leave SQLite with a partial transcript.
|
|
|
|
DESTRUCTIVE by default: every row for the session is DELETEd (and drops
|
|
out of the FTS index). For compaction that must preserve the
|
|
pre-compaction transcript under the same id, use
|
|
:meth:`archive_and_compact` instead.
|
|
|
|
Pass ``active_only=True`` to replace ONLY the live (``active = 1``) rows,
|
|
leaving soft-archived rows (``active = 0`` — e.g. the ``compacted = 1``
|
|
turns that :meth:`archive_and_compact` keeps on disk for #38763
|
|
durability, or rewind/undo rows) untouched. Callers that share a session
|
|
id with an agent already running in-place compaction must use this so a
|
|
full-history rewrite doesn't wipe the rows the agent deliberately
|
|
archived. ``message_count``/``tool_call_count`` then track the live set,
|
|
matching :meth:`archive_and_compact`.
|
|
|
|
Pass ``archive_dropped=True`` to SOFT-archive the live rows instead of
|
|
DELETEing them: the replaced turns stay on disk with ``active = 0``,
|
|
``compacted = 0`` — the same "the user took it back" marking
|
|
:meth:`rewind_to_message` applies — and stay readable via
|
|
:meth:`get_messages` with ``include_inactive=True``. This is the mode a
|
|
rewind/edit/regenerate must use: those flows overwrite a transcript the
|
|
user may not have meant to drop, and a plain DELETE also evicts the rows
|
|
from the FTS index, leaving nothing to recover from (#82756). It implies
|
|
active-only handling — already-archived rows are never touched — so
|
|
``active_only`` is redundant with it. The rewritten set is inserted as
|
|
fresh active rows exactly as in the destructive path, so the live view
|
|
is identical either way; only the durability of the dropped turns
|
|
differs.
|
|
|
|
Pass ``reject_active_turn_lease=True`` for user-initiated rewrites that
|
|
do not already own the cross-process turn lease. The lease check and
|
|
transcript mutation then share one write transaction, so a second
|
|
process cannot archive or replace a turn that is still being produced.
|
|
"""
|
|
|
|
active_clause = " AND active = 1" if active_only else ""
|
|
|
|
def _do(conn):
|
|
if reject_active_turn_lease:
|
|
self._check_transcript_write_guards(
|
|
conn,
|
|
session_id,
|
|
None,
|
|
reject_active_turn_lease=True,
|
|
reject_active_compression_lock=True,
|
|
)
|
|
else:
|
|
session = conn.execute(
|
|
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if (
|
|
session is not None
|
|
and session["ended_at"] is not None
|
|
and session["end_reason"] == "compression"
|
|
):
|
|
raise CompressionSessionClosedError(session_id)
|
|
if archive_dropped:
|
|
# Content-preserving UPDATE: the rows keep their FTS entries
|
|
# (the messages_fts triggers fire on INSERT / DELETE / UPDATE
|
|
# of content columns, not on `active`), so the replaced turns
|
|
# stay readable via get_messages(include_inactive=True) and
|
|
# searchable with include_inactive=True after the rewrite.
|
|
conn.execute(
|
|
"UPDATE messages SET active = 0 "
|
|
"WHERE session_id = ? AND active = 1",
|
|
(session_id,),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
f"DELETE FROM messages WHERE session_id = ?{active_clause}",
|
|
(session_id,),
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
total_messages, total_tool_calls = self._insert_message_rows(
|
|
conn, session_id, messages
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
|
|
(total_messages, total_tool_calls, session_id),
|
|
)
|
|
|
|
self._execute_write(_do)
|
|
|
|
def has_archived_messages(self, session_id: str) -> bool:
|
|
"""Return True if the session has any soft-archived (``active = 0``) rows.
|
|
|
|
Cheap existence probe — does not load rows. NOTE: production rewrite
|
|
paths no longer branch on this (they pass ``active_only=True``
|
|
unconditionally — a probe can fail open or race a concurrent
|
|
``archive_and_compact``, #80216); kept for tests and diagnostics.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1",
|
|
(session_id,),
|
|
)
|
|
return cursor.fetchone() is not None
|
|
|
|
def get_active_message_watermark(self, session_id: str) -> int:
|
|
"""MAX(id) of the session's active rows — the compression watermark.
|
|
|
|
Captured at compression START (before the slow provider summary call).
|
|
Every active row with ``id > watermark`` at commit time arrived
|
|
concurrently and must survive the compaction verbatim. Returns 0 for
|
|
an empty/unknown session.
|
|
"""
|
|
if not session_id:
|
|
return 0
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT COALESCE(MAX(id), 0) FROM messages "
|
|
"WHERE session_id = ? AND active = 1",
|
|
(session_id,),
|
|
).fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
def archive_and_compact(
|
|
self,
|
|
session_id: str,
|
|
compacted_messages: List[Dict[str, Any]],
|
|
model_config_patch: Optional[Dict[str, Any]] = None,
|
|
watermark: Optional[int] = None,
|
|
lock_holder: Optional[str] = None,
|
|
tail_count: int = 0,
|
|
) -> int:
|
|
"""Non-destructive in-place compaction for a single durable session id.
|
|
|
|
Soft-archives the active messages (``active = 0``) and inserts
|
|
*compacted_messages* as fresh active rows — atomically, in one write
|
|
transaction. The conversation keeps ONE session id for life (#38763)
|
|
WITHOUT destroying history:
|
|
|
|
- The live-context load (:meth:`get_messages_as_conversation`,
|
|
:meth:`get_messages`) filters ``active = 1`` by default, so the model
|
|
reloads ONLY the compacted set.
|
|
- The archived pre-compaction turns stay on disk (active=0) and stay
|
|
DISCOVERABLE: they are marked compacted=1, and search_messages()
|
|
includes compacted=1 rows by default — so session_search still finds
|
|
them, unlike rewind/undo rows (active=0, compacted=0) which stay
|
|
hidden. They remain in the FTS index (the messages_fts* triggers
|
|
index on INSERT / drop on DELETE and don't key on active/compacted;
|
|
flipping to active=0 is a content-preserving UPDATE) and are
|
|
recoverable via get_messages(..., include_inactive=True).
|
|
|
|
Concurrent-append safety (#75316): when *watermark* is provided (the
|
|
value of :meth:`get_active_message_watermark` captured at compression
|
|
START), rows that arrived during the slow provider summary call
|
|
(``id > watermark``) are NOT summarized away. They are re-sequenced
|
|
after the compacted set by a pure-SQL column clone (every column
|
|
except ``id`` — content, api_content, platform_message_id, token
|
|
counts, reasoning sidecars all survive byte-exact, and the FTS
|
|
triggers index the clones naturally), and the originals are archived.
|
|
NOTE: re-sequencing assigns the tail rows fresh ids; consumers that
|
|
reference durable row ids re-resolve by content (see 3e8ab0610).
|
|
``watermark=None`` preserves the historical archive-everything
|
|
behavior.
|
|
|
|
Commit-fence safety: when *lock_holder* is provided, the commit
|
|
verifies INSIDE the transaction that the compression lock is still
|
|
held by that holder and unexpired — a compression whose lease was
|
|
reclaimed (crash cleanup, TTL expiry, competing writer) fails the
|
|
commit instead of clobbering the winner's transcript.
|
|
|
|
*tail_count* (default 0) names how many of the LAST rows of
|
|
*compacted_messages* are the verbatim carried-forward tail the
|
|
compressor protected rather than summarized (#86366). Those rows'
|
|
ORIGINALS — which this call archives as a side effect of the blanket
|
|
soft-archive — are superseded byte-identical duplicates, not
|
|
"summarized away" content, so they are stamped rewind-style
|
|
(``active=0, compacted=0``, hidden from search_messages) instead of
|
|
``compacted=1``. Without this the tail originals satisfy the recall
|
|
filter alongside their live clones and session_search returns every
|
|
carried-forward message once per compaction. Callers that cannot know
|
|
their tail shape keep the historical archive-everything behavior.
|
|
|
|
``message_count`` is set to the ACTIVE count after commit, matching
|
|
what the live load returns. ``model_config_patch`` is merged into the
|
|
session's JSON config in the same transaction; a ``None`` value
|
|
removes that key. Returns the new active count.
|
|
"""
|
|
|
|
def _do(conn):
|
|
if lock_holder is not None:
|
|
lock_row = conn.execute(
|
|
"SELECT holder, expires_at FROM compression_locks "
|
|
"WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if (
|
|
lock_row is None
|
|
or lock_row["holder"] != lock_holder
|
|
or float(lock_row["expires_at"]) <= time.time()
|
|
):
|
|
raise SessionCompressionInProgressError(
|
|
f"Compression lease for {session_id!r} lost before "
|
|
"commit; refusing to publish a stale compaction"
|
|
)
|
|
|
|
patched_model_config = None
|
|
if model_config_patch is not None:
|
|
# on_missing="raise": a prune/compaction must not commit
|
|
# against a vanished session row (the compressor's caller
|
|
# converts the raised error into a safe keep-the-original
|
|
# no-op), unlike the flag setters which tolerate missing rows.
|
|
patched_model_config = self._merge_model_config_json(
|
|
conn, session_id, model_config_patch, on_missing="raise"
|
|
)
|
|
|
|
# Concurrent tail: active rows that arrived after the watermark.
|
|
# Snapshot their ids and tool_calls now — the clone below needs a
|
|
# stable id list, and the tool-call count keeps sessions.* honest.
|
|
tail_ids: list[int] = []
|
|
tail_tool_calls = 0
|
|
if watermark is not None:
|
|
for row in conn.execute(
|
|
"SELECT id, tool_calls FROM messages "
|
|
"WHERE session_id = ? AND active = 1 AND id > ? "
|
|
"ORDER BY id",
|
|
(session_id, int(watermark)),
|
|
).fetchall():
|
|
tail_ids.append(int(row["id"]))
|
|
raw = row["tool_calls"]
|
|
if raw:
|
|
try:
|
|
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
|
tail_tool_calls += len(parsed) if isinstance(parsed, list) else 0
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# Soft-archive the live turns: active=0 hides them from the live
|
|
# context load, compacted=1 marks them as "summarized away" (vs
|
|
# rewind/undo's active=0+compacted=0, which means "user took it
|
|
# back"). search_messages includes compacted=1 rows by default so
|
|
# the pre-compaction transcript stays discoverable; live-context
|
|
# loads (active=1 only) still exclude them. Tail originals whose
|
|
# verbatim clones ride inside *compacted_messages* (tail_count)
|
|
# are superseded duplicates instead (#86366): they get the
|
|
# rewind-style flags so they stop matching the recall filter.
|
|
# Rewind-target ids: the originals of the carried-forward tail
|
|
# rows (tail_count), captured BEFORE any flag flips. Named apart
|
|
# from the watermark `tail_ids` below on purpose — the two are
|
|
# different sets (#86366): rewind targets sit AT/BELOW the
|
|
# watermark (the compressor only saw rows up to it), while
|
|
# `tail_ids` are concurrent appends ABOVE it. Without the bound,
|
|
# a concurrent append would steal a LIMIT slot and leave a real
|
|
# carried-forward original stamped compacted=1.
|
|
rewind_tail_ids: Optional[list[int]] = None
|
|
if tail_count > 0:
|
|
if watermark is not None:
|
|
tail_rows = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND active = 1 AND id <= ? "
|
|
"ORDER BY id DESC LIMIT ?",
|
|
(session_id, int(watermark), int(tail_count)),
|
|
).fetchall()
|
|
else:
|
|
tail_rows = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND active = 1 ORDER BY id DESC LIMIT ?",
|
|
(session_id, int(tail_count)),
|
|
).fetchall()
|
|
rewind_tail_ids = [int(row["id"]) for row in tail_rows]
|
|
|
|
# The watermark clone below re-inserts `tail_ids` rows byte-exact
|
|
# as live rows — their originals are the SAME superseded-duplicate
|
|
# class as the carried-forward tail (#86366), so they take the
|
|
# rewind flags too instead of double-matching the recall filter.
|
|
rewind_ids = [*(rewind_tail_ids or []), *tail_ids]
|
|
|
|
if rewind_ids:
|
|
placeholders = ",".join("?" for _ in rewind_ids)
|
|
conn.execute(
|
|
"UPDATE messages SET active = 0, compacted = 0 "
|
|
f"WHERE session_id = ? AND id IN ({placeholders})",
|
|
[session_id, *rewind_ids],
|
|
)
|
|
conn.execute(
|
|
"UPDATE messages SET active = 0, compacted = 1 "
|
|
"WHERE session_id = ? AND active = 1 "
|
|
f"AND id NOT IN ({placeholders})",
|
|
[session_id, *rewind_ids],
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE messages SET active = 0, compacted = 1 "
|
|
"WHERE session_id = ? AND active = 1",
|
|
(session_id,),
|
|
)
|
|
inserted, tool_calls_total = self._insert_message_rows(
|
|
conn, session_id, compacted_messages
|
|
)
|
|
|
|
if tail_ids:
|
|
# Re-sequence the concurrent tail after the compacted set via
|
|
# a pure-SQL column clone: no decode/re-encode round trip, no
|
|
# field drift — new id, active=1, compacted=0, all else exact.
|
|
placeholders = ",".join("?" for _ in tail_ids)
|
|
clone_cols = [
|
|
c for c in self._message_column_names(conn)
|
|
if c not in ("id", "active", "compacted")
|
|
]
|
|
col_list = ", ".join(clone_cols)
|
|
conn.execute(
|
|
f"INSERT INTO messages ({col_list}, active, compacted) "
|
|
f"SELECT {col_list}, 1, 0 FROM messages "
|
|
f"WHERE id IN ({placeholders}) ORDER BY id",
|
|
tail_ids,
|
|
)
|
|
inserted += len(tail_ids)
|
|
tool_calls_total += tail_tool_calls
|
|
|
|
# message_count / tool_call_count reflect the LIVE (active) set —
|
|
# the archived rows are still on disk but not part of the live count.
|
|
if model_config_patch is None:
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
|
|
(inserted, tool_calls_total, session_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = ?, tool_call_count = ?, "
|
|
"model_config = ? WHERE id = ?",
|
|
(inserted, tool_calls_total, patched_model_config, session_id),
|
|
)
|
|
return inserted
|
|
|
|
return self._execute_write(_do)
|
|
|
|
def _message_column_names(self, conn) -> List[str]:
|
|
"""Column names of the messages table, cached per-connection era."""
|
|
cached = getattr(self, "_message_columns_cache", None)
|
|
if cached:
|
|
return cached
|
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()]
|
|
self._message_columns_cache = cols
|
|
return cols
|
|
|
|
def set_latest_user_api_content(
|
|
self, session_id: str, content: Any, api_content: str
|
|
) -> int:
|
|
"""Backfill the ``api_content`` sidecar onto the newest ACTIVE user row.
|
|
|
|
In-place preflight compaction (:meth:`archive_and_compact`) inserts the
|
|
current turn's user row BEFORE the turn prologue composes the
|
|
prefetch/plugin sidecar, and the subsequent crash persist identity-skips
|
|
every compacted dict — without this backfill the stamped sidecar would
|
|
never land in the DB and any reload would replay clean content,
|
|
re-introducing the prompt-cache divergence the sidecar exists to close.
|
|
|
|
The ``content`` match is a defensive guard: if the newest active user
|
|
row is not the message the caller stamped (racing rewrite, unexpected
|
|
tail shape), nothing is written. Returns the number of rows updated
|
|
(0 or 1).
|
|
"""
|
|
encoded = self._encode_content(content)
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"UPDATE messages SET api_content = ? WHERE id = ("
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND role = 'user' AND active = 1 "
|
|
"ORDER BY id DESC LIMIT 1"
|
|
") AND content IS ?",
|
|
(_scrub_surrogates(api_content), session_id, encoded),
|
|
)
|
|
return cursor.rowcount
|
|
|
|
return self._execute_write(_do)
|
|
|
|
def get_messages(
|
|
self,
|
|
session_id: str,
|
|
include_inactive: bool = False,
|
|
include_compacted: bool = False,
|
|
limit: Optional[int] = None,
|
|
offset: int = 0,
|
|
latest: bool = False,
|
|
after_id: Optional[int] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Load messages for a session in insertion order.
|
|
|
|
By default only active messages are returned. Pass
|
|
``include_inactive=True`` to load soft-deleted rows (e.g. for
|
|
audit / debug views of rewound history). See
|
|
:meth:`rewind_to_message` for the soft-delete mechanic.
|
|
|
|
Pass ``include_compacted=True`` to additionally load rows preserved
|
|
by in-place context compaction (``active=0, compacted=1``). Those are
|
|
durable display history, not soft-deleted rows — a user-visible
|
|
transcript read must not drop them, or earlier turns silently become
|
|
unreachable once the UI exhausts its active-only window. Soft-deleted
|
|
Undo/Rewind rows (``active=0, compacted=0``) stay excluded; use
|
|
``include_inactive`` for those.
|
|
|
|
Ordered by AUTOINCREMENT id (true insertion order) rather than
|
|
timestamp — see c03acca50 for the WSL2 clock-regression rationale.
|
|
|
|
When ``limit`` is provided, returns at most ``limit`` messages
|
|
starting from ``offset`` (0-based, in insertion order). Enables
|
|
pagination for the API endpoint to avoid loading entire transcripts.
|
|
With ``latest=True``, the offset is measured back from the newest
|
|
message and the selected page is still returned in chronological
|
|
order. ``offset`` alone (without ``limit``) also pages — SQLite
|
|
requires a LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1``
|
|
(unbounded).
|
|
|
|
``after_id`` enables keyset pagination (``id > after_id``): O(1)
|
|
page seeks on huge transcripts where OFFSET degrades to O(n) per
|
|
page. Ascending order only (incompatible with ``latest``/``offset``).
|
|
"""
|
|
if after_id is not None and (latest or offset):
|
|
raise ValueError("after_id is incompatible with latest/offset paging")
|
|
if after_id is not None and include_compacted:
|
|
raise ValueError("after_id is incompatible with include_compacted (deduped display reads use offset paging)")
|
|
if include_inactive:
|
|
# Audit / debug reads: every row, including soft-deleted.
|
|
active_clause = ""
|
|
elif include_compacted:
|
|
# Display history: active rows plus rows preserved by in-place
|
|
# compaction (active=0, compacted=1), but never soft-deleted
|
|
# Undo/Rewind rows (active=0, compacted=0).
|
|
active_clause = " AND (active = 1 OR compacted = 1)"
|
|
else:
|
|
active_clause = " AND active = 1"
|
|
keyset_clause = " AND id > ?" if after_id is not None else ""
|
|
sql = (
|
|
"SELECT * FROM messages WHERE session_id = ?"
|
|
f"{active_clause}{keyset_clause} ORDER BY id {'DESC' if latest else 'ASC'}"
|
|
)
|
|
params: list = [session_id]
|
|
if after_id is not None:
|
|
params.append(after_id)
|
|
if include_compacted:
|
|
# Compaction epochs copy the protected tail into each new
|
|
# generation, so the same logical message can exist as several
|
|
# rows (identical role/content/timestamp) with different active
|
|
# flags and ids. A display read must surface each message exactly
|
|
# once: prefer the live row, then the newest generation. Read the
|
|
# full display set (a session's rows are bounded; the UI-level
|
|
# 500-row cap lives in the endpoint, not here), dedupe in Python,
|
|
# then apply paging.
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM messages WHERE session_id = ?" + active_clause
|
|
+ " ORDER BY id ASC",
|
|
[session_id],
|
|
)
|
|
all_rows = cursor.fetchall()
|
|
seen: dict = {}
|
|
for row in all_rows:
|
|
dedupe_content = row["content"]
|
|
if row["role"] == "user":
|
|
from agent.context_compressor import split_user_originated_turn
|
|
|
|
candidate = {
|
|
"role": "user",
|
|
"content": self._decode_content(row["content"]),
|
|
"display_kind": row["display_kind"],
|
|
"display_metadata": self._decode_display_metadata(
|
|
row["display_metadata"]
|
|
),
|
|
}
|
|
handoff, live_view = split_user_originated_turn(candidate)
|
|
if handoff is not None and live_view is not None:
|
|
dedupe_content = self._encode_content(
|
|
live_view.get("content")
|
|
)
|
|
# Tool fields participate in the dedupe key: compaction copies
|
|
# them verbatim, so identical tool messages across generations
|
|
# still collapse, while distinct tool calls that happen to
|
|
# share role/content/timestamp are never merged.
|
|
key = (
|
|
row["role"],
|
|
dedupe_content,
|
|
row["timestamp"],
|
|
row["tool_call_id"],
|
|
row["tool_calls"],
|
|
row["tool_name"],
|
|
)
|
|
cur = seen.get(key)
|
|
if cur is None or (row["active"], row["id"]) > (cur["active"], cur["id"]):
|
|
seen[key] = row
|
|
rows = sorted(seen.values(), key=lambda r: r["id"])
|
|
if latest:
|
|
rows = rows[::-1]
|
|
rows = rows[offset:]
|
|
if limit is not None:
|
|
rows = rows[:limit]
|
|
if latest:
|
|
rows = rows[::-1]
|
|
else:
|
|
if limit is not None or offset:
|
|
# SQLite's OFFSET requires LIMIT; -1 means "no limit".
|
|
sql += " LIMIT ? OFFSET ?"
|
|
params.extend([-1 if limit is None else limit, offset])
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(sql, params)
|
|
rows = cursor.fetchall()
|
|
if latest:
|
|
rows.reverse()
|
|
result = []
|
|
for row in rows:
|
|
msg = dict(row)
|
|
if msg.pop("_compressed_summary", 0):
|
|
msg["_compressed_summary"] = True
|
|
if "content" in msg:
|
|
msg["content"] = self._decode_content(msg["content"])
|
|
if msg.get("tool_calls"):
|
|
try:
|
|
msg["tool_calls"] = json.loads(msg["tool_calls"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
|
|
msg["tool_calls"] = []
|
|
if msg.get("display_metadata") is not None:
|
|
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
|
|
result.append(msg)
|
|
return result
|
|
|
|
def find_pr_url_messages(self, session_ids: List[str]) -> List[Dict[str, Any]]:
|
|
"""Tool results in these sessions that mention a GitHub PR url.
|
|
|
|
A candidate scan, deliberately loose: it hands back every tool result
|
|
containing ``/pull/`` and leaves the caller to decide which ones make a
|
|
claim (see the desktop's PR recovery, which only accepts an output that
|
|
is a bare PR url — the signature of ``gh pr create``). Ordered
|
|
oldest-first per session so the caller can take the last match.
|
|
"""
|
|
found: List[Dict[str, Any]] = []
|
|
ids = [s for s in session_ids if s]
|
|
for start in range(0, len(ids), 900): # SQLite's bound-variable ceiling.
|
|
chunk = ids[start : start + 900]
|
|
placeholders = ",".join("?" * len(chunk))
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
f"""SELECT session_id, content FROM messages
|
|
WHERE session_id IN ({placeholders})
|
|
AND role = 'tool' AND content LIKE '%/pull/%'
|
|
ORDER BY id ASC""",
|
|
chunk,
|
|
).fetchall()
|
|
found.extend({"session_id": row[0], "content": row[1]} for row in rows)
|
|
return found
|
|
|
|
def get_messages_around(
|
|
self,
|
|
session_id: str,
|
|
around_message_id: int,
|
|
window: int = 5,
|
|
) -> Dict[str, Any]:
|
|
"""Load a window of messages anchored on a specific message id.
|
|
|
|
Returns a dict with:
|
|
- ``window``: up to ``window`` messages before the anchor, the anchor
|
|
itself, and up to ``window`` messages after, ordered by id ascending.
|
|
- ``messages_before``: count of messages strictly before the anchor
|
|
still in the session (== window unless we hit the start).
|
|
- ``messages_after``: count of messages strictly after the anchor
|
|
still in the session (== window unless we hit the end).
|
|
|
|
Used by ``session_search`` for both the discovery shape (anchored on the
|
|
FTS5 match) and the scroll shape (anchored on any message id). The
|
|
``messages_before`` / ``messages_after`` counts let the caller detect
|
|
session boundaries: when either is less than ``window``, the agent has
|
|
reached one end of the session.
|
|
|
|
Returns an empty window when ``around_message_id`` is not a real id in
|
|
``session_id`` — callers decide how to surface that.
|
|
"""
|
|
if window < 0:
|
|
window = 0
|
|
with self._read_ctx() as conn:
|
|
# Confirm the anchor exists in this session.
|
|
anchor_exists = conn.execute(
|
|
"SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1",
|
|
(around_message_id, session_id),
|
|
).fetchone()
|
|
if not anchor_exists:
|
|
return {"window": [], "messages_before": 0, "messages_after": 0}
|
|
|
|
# Two queries: anchor + before (DESC, take window+1), and after
|
|
# (ASC, take window). Final order is id ASC.
|
|
before_rows = conn.execute(
|
|
"SELECT * FROM messages "
|
|
"WHERE session_id = ? AND id <= ? "
|
|
"ORDER BY id DESC LIMIT ?",
|
|
(session_id, around_message_id, window + 1),
|
|
).fetchall()
|
|
after_rows = conn.execute(
|
|
"SELECT * FROM messages "
|
|
"WHERE session_id = ? AND id > ? "
|
|
"ORDER BY id ASC LIMIT ?",
|
|
(session_id, around_message_id, window),
|
|
).fetchall()
|
|
|
|
# before_rows is DESC; reverse so it's ASC, then concatenate after_rows.
|
|
rows = list(reversed(before_rows)) + list(after_rows)
|
|
result = []
|
|
for row in rows:
|
|
msg = dict(row)
|
|
if "content" in msg:
|
|
msg["content"] = self._decode_content(msg["content"])
|
|
if msg.get("tool_calls"):
|
|
try:
|
|
msg["tool_calls"] = json.loads(msg["tool_calls"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning(
|
|
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
|
|
)
|
|
msg["tool_calls"] = []
|
|
if msg.get("display_metadata") is not None:
|
|
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
|
|
result.append(msg)
|
|
|
|
# before_rows includes the anchor itself; subtract 1 for the count of
|
|
# messages strictly before the anchor in the returned slice.
|
|
messages_before = max(0, len(before_rows) - 1)
|
|
messages_after = len(after_rows)
|
|
return {
|
|
"window": result,
|
|
"messages_before": messages_before,
|
|
"messages_after": messages_after,
|
|
}
|
|
|
|
def resolve_resume_session_id(self, session_id: str) -> str:
|
|
"""Redirect a resume target to the descendant session that holds the messages.
|
|
|
|
Context compression ends the current session and forks a new child session
|
|
(linked via ``parent_session_id``). The flush cursor is reset, so the
|
|
child is where new messages actually land — the parent ends up with
|
|
``message_count = 0`` rows unless messages had already been flushed to
|
|
it before compression. See #15000.
|
|
|
|
This helper walks ``parent_session_id`` forward from ``session_id`` and
|
|
returns the descendant in the chain that has the **most recent** messages.
|
|
Unlike the original logic, it does NOT short-circuit when the starting
|
|
session already has messages — a descendant that was created by
|
|
compression may hold the continuation content and should be preferred
|
|
by the WebUI and gateway for ``--resume`` and session loading.
|
|
|
|
If no descendant (including the starting session) has any messages,
|
|
the original ``session_id`` is returned unchanged.
|
|
|
|
The chain is always walked via the child whose ``started_at`` is
|
|
latest; that matches the single-chain shape that compression creates.
|
|
A depth cap (32) guards against accidental loops in malformed data.
|
|
"""
|
|
if not session_id:
|
|
return session_id
|
|
|
|
# Follow the compression-continuation chain forward to the live tip
|
|
# FIRST. Auto-compression ends the current session and forks a
|
|
# continuation child, but a long-lived parent keeps its own flushed
|
|
# message rows — so the empty-head walk below never redirects it, and
|
|
# resuming the parent id reloads the pre-compression transcript while
|
|
# the turns generated *after* compression (and their responses) sit in
|
|
# the continuation. ``get_compression_tip`` is lineage-aware: it only
|
|
# follows children whose parent ended with ``end_reason='compression'``
|
|
# (created after the parent was ended), so delegation / branch children
|
|
# never hijack the resume. This is the fix for the desktop "I came back
|
|
# and the reply isn't there" report on large sessions.
|
|
try:
|
|
tip = self.get_compression_tip(session_id)
|
|
except Exception:
|
|
tip = session_id
|
|
if tip and tip != session_id:
|
|
session_id = tip
|
|
|
|
with self._read_ctx() as conn:
|
|
current = session_id
|
|
seen = {current}
|
|
best = None # tracks the last (deepest) node with messages
|
|
|
|
for _ in range(32):
|
|
# Check if the current node has messages.
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
|
|
(current,),
|
|
).fetchone()
|
|
except Exception:
|
|
return session_id
|
|
if row is not None:
|
|
best = current
|
|
|
|
# Walk to the most-recently-started child — but skip explicit
|
|
# branch (`_branched_from`), delegate/subagent (`_delegate_from`),
|
|
# reset-continuation (`_reset_from` or the legacy same-key
|
|
# heuristic — a post-reset conversation must never be reached
|
|
# by resuming the parent the user reset away), and tool
|
|
# children. They also carry a ``parent_session_id`` yet
|
|
# are NOT compression continuations; following them would hijack
|
|
# the resume target to an unrelated session (e.g. a subagent
|
|
# run). This mirrors the child-exclusion in ``get_compression_tip``.
|
|
try:
|
|
child_row = conn.execute(
|
|
"SELECT id FROM sessions AS child "
|
|
"WHERE child.parent_session_id = ? "
|
|
" AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL "
|
|
" AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL "
|
|
" AND json_extract(COALESCE(child.model_config, '{}'), '$._reset_from') IS NULL "
|
|
f" AND NOT {_legacy_reset_child_sql('child', _RESET_END_REASONS_SQL)} "
|
|
" AND COALESCE(child.source, '') != 'tool' "
|
|
"ORDER BY child.started_at DESC, child.id DESC LIMIT 1",
|
|
(current,),
|
|
).fetchone()
|
|
except Exception:
|
|
return session_id
|
|
if child_row is None:
|
|
break
|
|
child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
|
|
if not child_id or child_id in seen:
|
|
break
|
|
seen.add(child_id)
|
|
current = child_id
|
|
|
|
return best if best is not None else session_id
|
|
|
|
def get_messages_as_conversation(
|
|
self,
|
|
session_id: str,
|
|
include_ancestors: bool = False,
|
|
include_inactive: bool = False,
|
|
repair_alternation: bool = False,
|
|
include_row_ids: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Load messages in the OpenAI conversation format (role + content dicts).
|
|
Used by the gateway to restore conversation history.
|
|
|
|
By default only active messages are returned. Pass
|
|
``include_inactive=True`` to load soft-deleted (rewound) rows
|
|
as well. See :meth:`rewind_to_message`.
|
|
|
|
``repair_alternation=True`` runs ``repair_message_sequence`` over the
|
|
loaded list before returning it. Callers that restore a session for
|
|
LIVE REPLAY should pass it: a durable alternation violation (e.g. a
|
|
``user;user`` pair left by a turn that persisted no assistant row)
|
|
otherwise re-triggers the pre-request defensive repair on every
|
|
single request for the rest of the session's life — the repair
|
|
mutates only the per-request list, never the stored transcript.
|
|
Inspection/export consumers keep the default and see the transcript
|
|
verbatim.
|
|
"""
|
|
session_ids = [session_id]
|
|
if include_ancestors and not self._is_explicit_branch_session(session_id):
|
|
session_ids = self._session_lineage_root_to_tip(session_id)
|
|
|
|
active_clause = "" if include_inactive else " AND active = 1"
|
|
with self._read_ctx() as conn:
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
rows = conn.execute(
|
|
f"SELECT {self._CONVERSATION_ROW_COLUMNS} "
|
|
f"FROM messages WHERE session_id IN ({placeholders})"
|
|
# Order by AUTOINCREMENT id (true insertion order), NOT timestamp:
|
|
# append_message stamps rows with time.time(), which is not
|
|
# monotonic (WSL2, NTP steps, VM/laptop sleep resume). A later
|
|
# row can carry an earlier timestamp than its predecessor, and
|
|
# ORDER BY timestamp would then sort an assistant tool_calls row
|
|
# after its tool response, breaking tool-call/response adjacency
|
|
# and triggering an HTTP 400 on replay. This matches get_messages
|
|
# — see c03acca50 for the original fix.
|
|
f"{active_clause} ORDER BY id",
|
|
tuple(session_ids),
|
|
).fetchall()
|
|
|
|
return self._rows_to_conversation(
|
|
rows,
|
|
session_id=session_id,
|
|
include_ancestors=include_ancestors,
|
|
repair_alternation=repair_alternation,
|
|
include_row_ids=include_row_ids,
|
|
)
|
|
|
|
# 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.
|
|
_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, "
|
|
"api_content, display_kind, display_metadata"
|
|
)
|
|
|
|
def _rows_to_conversation(
|
|
self,
|
|
rows,
|
|
*,
|
|
session_id: str,
|
|
include_ancestors: bool,
|
|
repair_alternation: bool,
|
|
include_row_ids: bool = False,
|
|
include_summary_markers: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Decode fetched message rows into the OpenAI conversation format.
|
|
|
|
Extracted from get_messages_as_conversation so get_resume_conversations
|
|
can build the model-fed and display views from one SELECT. ``rows`` must
|
|
already be ordered by ``id`` (insertion order) and filtered to the
|
|
desired session set / active state by the caller.
|
|
"""
|
|
messages = []
|
|
# Watermark rotation column-clones concurrent tail rows into the child
|
|
# after the new summary, so the copies need not be adjacent. Index the
|
|
# exact durable clone identity while decoding instead of rescanning the
|
|
# whole accumulated lineage for every user row.
|
|
exact_user_clones: Dict[Tuple[Any, str], Dict[str, Any]] = {}
|
|
for row in rows:
|
|
content = self._decode_content(row["content"])
|
|
if row["role"] in {"user", "assistant"} and isinstance(content, str):
|
|
content = sanitize_context(content).strip()
|
|
msg = {"role": row["role"], "content": content}
|
|
# Born durable (#92231): this dict is materialized FROM a durable
|
|
# row, so stamp the persistence marker at the source instead of
|
|
# relying on every restore caller to thread the loaded list back
|
|
# through a flush as ``conversation_history=`` — any
|
|
# identity-losing handoff (compression's durable-snapshot
|
|
# adoption, incremental persists with no history arg) would
|
|
# otherwise re-append the ENTIRE transcript on flush.
|
|
# Underscore-prefixed like ``_row_id``: every transport strips it
|
|
# before the wire, and compression's assembly copies deliberately
|
|
# strip it so rotated child handoffs still flush (see
|
|
# _fresh_compaction_message_copy).
|
|
msg[_DB_PERSISTED_MARKER_KEY] = True
|
|
# Durable per-message identity for surfaces that need to address a
|
|
# specific row later (desktop reactions). OPT-IN: only the gateway
|
|
# asks for it — every other consumer (ACP restore, export,
|
|
# inspection) gets the transcript in its historical shape.
|
|
# Underscore-prefixed so every transport's convert_messages()
|
|
# strips it before the wire.
|
|
if include_row_ids and row["id"] is not None:
|
|
msg["_row_id"] = row["id"]
|
|
# api_content is the byte-fidelity sidecar: the exact string sent
|
|
# to the API when it differed from the clean content. Returned
|
|
# VERBATIM — no sanitize_context, no strip — because the replay
|
|
# path substitutes it for content to keep the provider prompt
|
|
# cache prefix byte-stable across turns. Cleaning it here would
|
|
# re-introduce the divergence it exists to remove.
|
|
if row["api_content"]:
|
|
msg["api_content"] = row["api_content"]
|
|
if row["display_kind"]:
|
|
msg["display_kind"] = row["display_kind"]
|
|
if row["display_metadata"]:
|
|
decoded = self._decode_display_metadata(row["display_metadata"])
|
|
if decoded is not None:
|
|
msg["display_metadata"] = decoded
|
|
if include_summary_markers and row["_compressed_summary"]:
|
|
msg["_compressed_summary"] = True
|
|
if row["timestamp"]:
|
|
msg["timestamp"] = row["timestamp"]
|
|
if row["tool_call_id"]:
|
|
msg["tool_call_id"] = row["tool_call_id"]
|
|
if row["tool_name"]:
|
|
msg["tool_name"] = row["tool_name"]
|
|
if row["effect_disposition"]:
|
|
msg["effect_disposition"] = row["effect_disposition"]
|
|
if row["tool_calls"]:
|
|
try:
|
|
msg["tool_calls"] = json.loads(row["tool_calls"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
|
|
msg["tool_calls"] = []
|
|
# Surface the platform-side message id (e.g. yuanbao msg_id,
|
|
# telegram update_id) so platform-specific flows like recall
|
|
# can match by external identifier instead of having to fall
|
|
# back to content-match heuristics. Exposed as ``message_id``
|
|
# for backward compatibility with the JSONL transcript shape.
|
|
if row["platform_message_id"]:
|
|
msg["message_id"] = row["platform_message_id"]
|
|
if row["observed"]:
|
|
msg["observed"] = True
|
|
# Restore reasoning fields on assistant messages so providers
|
|
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
|
|
# coherent multi-turn reasoning context.
|
|
if row["role"] == "assistant":
|
|
if row["finish_reason"]:
|
|
msg["finish_reason"] = row["finish_reason"]
|
|
if row["reasoning"]:
|
|
msg["reasoning"] = row["reasoning"]
|
|
if row["reasoning_content"] is not None:
|
|
msg["reasoning_content"] = row["reasoning_content"]
|
|
if row["reasoning_details"]:
|
|
try:
|
|
msg["reasoning_details"] = json.loads(row["reasoning_details"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to deserialize reasoning_details, falling back to None")
|
|
msg["reasoning_details"] = None
|
|
if row["codex_reasoning_items"]:
|
|
try:
|
|
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
|
|
msg["codex_reasoning_items"] = None
|
|
if row["codex_message_items"]:
|
|
try:
|
|
msg["codex_message_items"] = json.loads(row["codex_message_items"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to deserialize codex_message_items, falling back to None")
|
|
msg["codex_message_items"] = None
|
|
if include_ancestors:
|
|
canonical_content, _is_composite = (
|
|
self._canonical_replayed_user_content(msg)
|
|
)
|
|
exact_clone_key = self._exact_replayed_user_clone_key(
|
|
msg.get("timestamp"), canonical_content
|
|
)
|
|
previous_exact = (
|
|
exact_user_clones.get(exact_clone_key)
|
|
if exact_clone_key is not None
|
|
else None
|
|
)
|
|
duplicate = None
|
|
if previous_exact is not None:
|
|
previous_index = next(
|
|
(
|
|
index
|
|
for index, candidate in enumerate(messages)
|
|
if candidate is previous_exact
|
|
),
|
|
None,
|
|
)
|
|
if previous_index is not None:
|
|
duplicate = (previous_index, True)
|
|
if duplicate is None:
|
|
duplicate = self._find_duplicate_replayed_user_message(
|
|
messages, msg
|
|
)
|
|
if duplicate is not None:
|
|
duplicate_index, prefer_current = duplicate
|
|
if prefer_current:
|
|
# A rotated compression child can carry the same live
|
|
# ask as the parent row plus the only surviving summary
|
|
# scaffold. Keep the child carrier (and its durable row
|
|
# id), not the simpler ancestor copy.
|
|
messages.pop(duplicate_index)
|
|
else:
|
|
continue
|
|
messages.append(msg)
|
|
if include_ancestors and exact_clone_key is not None:
|
|
exact_user_clones[exact_clone_key] = msg
|
|
# DEFENSE-IN-DEPTH against background-review session pollution: a forked
|
|
# skill/memory review that (in older builds, before the _persist_disabled
|
|
# fix) shared the parent's session_id wrote its harness turn into this
|
|
# real session. The harness is a user/system message instructing the
|
|
# agent to "Review the conversation above and update the skill library /
|
|
# save to memory" under a hard tool restriction; re-loading it as live
|
|
# history makes the agent adopt the curator role and refuse the user's
|
|
# actual task. Strip any such harness message AND the curator-mode
|
|
# assistant reply immediately following it, so a polluted session
|
|
# resumes clean even if stray rows exist.
|
|
messages = _strip_background_review_harness(messages)
|
|
# DEFENSE-IN-DEPTH against #78148: before that fix, a bare tool-call
|
|
# marker (e.g. "[memory]") could get cached as a fallback and
|
|
# persisted as if it were the model's real answer. Sessions written
|
|
# before the fix can still carry those rows — clear the stray
|
|
# content on load so replaying history doesn't re-teach the model
|
|
# to keep emitting the marker. No-op for unaffected sessions.
|
|
messages = _strip_stale_tool_call_markers(messages)
|
|
if repair_alternation and messages:
|
|
# Lazy import: hermes_state already depends on agent.* (see
|
|
# sanitize_context above), but keep this optional path from
|
|
# widening the import surface at module load.
|
|
from agent.agent_runtime_helpers import repair_message_sequence
|
|
|
|
repaired = repair_message_sequence(None, messages)
|
|
if repaired:
|
|
logger.info(
|
|
"Repaired %d message-alternation violation(s) while "
|
|
"restoring session %s — durable transcript kept them, "
|
|
"see repair_message_sequence",
|
|
repaired,
|
|
session_id,
|
|
)
|
|
return messages
|
|
|
|
def get_resume_conversations(
|
|
self, session_id: str
|
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Return ``(model_history, display_history)`` for a session resume in ONE SELECT.
|
|
|
|
``session.resume`` needs two projections of the same lineage:
|
|
|
|
- ``model_history`` — the tip session's active rows, alternation-repaired
|
|
(the live-replay working conversation). Equivalent to
|
|
``get_messages_as_conversation(session_id, repair_alternation=True)``.
|
|
- ``display_history`` — the full compression lineage (ancestors → tip),
|
|
verbatim, with replayed-user dedup. Explicit ``/branch`` sessions are
|
|
excluded from this lineage because their own rows already contain the
|
|
copied transcript; including the live parent's rows would let messages
|
|
written to the original after the fork leak into the branch.
|
|
|
|
The display fetch already reads a superset of the model fetch (the tip
|
|
rows are part of the lineage), so serving both from one lineage SELECT
|
|
halves the resume's DB work versus two separate calls, with byte-identical
|
|
output (see test_get_resume_conversations_matches_separate_reads).
|
|
"""
|
|
session_ids = (
|
|
[session_id]
|
|
if self._is_explicit_branch_session(session_id)
|
|
else self._session_lineage_root_to_tip(session_id)
|
|
)
|
|
with self._read_ctx() as conn:
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
rows = conn.execute(
|
|
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
|
|
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
|
|
# ORDER BY id (insertion order) — see get_messages_as_conversation
|
|
# for why timestamp ordering is unsafe.
|
|
"ORDER BY id",
|
|
tuple(session_ids),
|
|
).fetchall()
|
|
|
|
# Tip rows are exactly the model-fed set (get_messages_as_conversation
|
|
# with session_ids=[session_id]); filtering the lineage fetch preserves
|
|
# their relative id order.
|
|
tip_rows = [r for r in rows if r["session_id"] == session_id]
|
|
model_history = self._rows_to_conversation(
|
|
tip_rows,
|
|
session_id=session_id,
|
|
include_ancestors=False,
|
|
repair_alternation=True,
|
|
include_row_ids=True,
|
|
# Pre-compress checkpointing: the resumed model history must keep
|
|
# the summary marker so checkpoint providers can exclude derivative
|
|
# summaries after a process restart (marker survives restart).
|
|
include_summary_markers=True,
|
|
)
|
|
display_history = self._rows_to_conversation(
|
|
rows,
|
|
session_id=session_id,
|
|
include_ancestors=True,
|
|
repair_alternation=False,
|
|
include_row_ids=True,
|
|
)
|
|
return model_history, display_history
|
|
|
|
def get_resume_message_count(self, session_id: str) -> int:
|
|
"""Count active rows that a full resume would materialize."""
|
|
session_ids = self._session_lineage_root_to_tip(session_id)
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
f"SELECT COUNT(*) FROM messages "
|
|
f"WHERE session_id IN ({placeholders}) AND active = 1",
|
|
tuple(session_ids),
|
|
).fetchone()
|
|
return int(row[0] if row else 0)
|
|
|
|
def assert_resume_safe(
|
|
self,
|
|
session_id: str,
|
|
max_messages: Optional[int] = None,
|
|
) -> int:
|
|
"""Return resume row count or reject a transcript too large to load.
|
|
|
|
``max_messages=None`` resolves the limit from config
|
|
(``sessions.max_resume_messages``); 0 disables the guard and returns
|
|
the (bounded) count without raising.
|
|
"""
|
|
if max_messages is None:
|
|
max_messages = resolved_max_resume_messages()
|
|
if max_messages < 0:
|
|
raise ValueError("max_messages must be non-negative")
|
|
if max_messages == 0:
|
|
# Guard disabled by config — skip counting entirely. Every live
|
|
# caller invokes this for its raise side effect and ignores the
|
|
# return value, and an unbounded lineage COUNT here would do the
|
|
# exact pathological work the disable exists to avoid.
|
|
return 0
|
|
session_ids = self._session_lineage_root_to_tip(session_id)
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM ("
|
|
f"SELECT 1 FROM messages WHERE session_id IN ({placeholders}) "
|
|
"AND active = 1 LIMIT ?"
|
|
")",
|
|
(*session_ids, max_messages + 1),
|
|
).fetchone()
|
|
message_count = int(row[0] if row else 0)
|
|
if message_count > max_messages:
|
|
raise SessionResumeTooLargeError(message_count, max_messages)
|
|
return message_count
|
|
|
|
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
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM ("
|
|
"SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?"
|
|
")",
|
|
(session_id, max_messages + 1),
|
|
).fetchone()
|
|
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 get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
|
|
"""Return the ancestor-only display messages for a session lineage.
|
|
|
|
These are messages from parent/grandparent sessions (compression
|
|
ancestors) that appear in the display transcript but NOT in the
|
|
tip session's model-fed history. Used by ``session.resume`` to
|
|
build the ``display_history_prefix`` that ``_live_session_payload``
|
|
prepends to the live model history.
|
|
|
|
Previously the prefix was calculated as
|
|
``display_history[:len(display) - len(raw)]``, but that overcounts
|
|
when ``repair_message_sequence`` removes messages from the MIDDLE
|
|
of the tip history (e.g. verification candidates collapsed by the
|
|
consecutive-assistant merge) — the length difference includes both
|
|
ancestor messages AND repair-removed tip messages, but the slice
|
|
only captures the first N display messages (which are tip messages
|
|
when there are no ancestors), causing duplication. This method
|
|
returns ONLY the genuine ancestor messages, identified by
|
|
``session_id != tip_session_id``. (#65919)
|
|
"""
|
|
if self._is_explicit_branch_session(session_id):
|
|
return []
|
|
|
|
session_ids = self._session_lineage_root_to_tip(session_id)
|
|
if len(session_ids) <= 1:
|
|
return []
|
|
with self._read_ctx() as conn:
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
rows = conn.execute(
|
|
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
|
|
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
|
|
"ORDER BY id",
|
|
tuple(session_ids),
|
|
).fetchall()
|
|
ancestor_ids = {
|
|
int(row["id"])
|
|
for row in rows
|
|
if row["session_id"] != session_id and row["id"] is not None
|
|
}
|
|
if not ancestor_ids:
|
|
return []
|
|
lineage = self._rows_to_conversation(
|
|
rows,
|
|
session_id=session_id,
|
|
include_ancestors=True,
|
|
repair_alternation=False,
|
|
include_row_ids=True,
|
|
)
|
|
prefix: List[Dict[str, Any]] = []
|
|
for message in lineage:
|
|
if message.get("_row_id") not in ancestor_ids:
|
|
continue
|
|
projected = message.copy()
|
|
projected.pop("_row_id", None)
|
|
prefix.append(projected)
|
|
return prefix
|
|
|
|
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
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT model_config FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
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 get_conversation_root(self, session_id: str) -> str:
|
|
"""Return the ROOT id of *session_id*'s lineage chain.
|
|
|
|
The root is the stable "conversation id": context compression
|
|
rotates ``session_id`` to a new segment linked via
|
|
``parent_session_id``, and delegate subagents hang off their
|
|
parent the same way. Walking to the root gives every segment of
|
|
one user-facing conversation (and its delegation tree) a single
|
|
identifier — used for Nous Portal ``conversation=`` usage tagging.
|
|
Returns *session_id* unchanged when it has no recorded parent.
|
|
"""
|
|
chain = self._session_lineage_root_to_tip(session_id)
|
|
return (chain[0] if chain and chain[0] else session_id)
|
|
|
|
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]
|
|
|
|
@staticmethod
|
|
def _canonical_replayed_user_content(
|
|
msg: Dict[str, Any],
|
|
) -> Tuple[Any, bool]:
|
|
"""Return canonical live content and whether *msg* is composite."""
|
|
if msg.get("role") != "user":
|
|
return None, False
|
|
|
|
from agent.context_compressor import split_user_originated_turn
|
|
|
|
handoff, live_view = split_user_originated_turn(msg)
|
|
is_composite = handoff is not None and live_view is not None
|
|
return (
|
|
live_view.get("content")
|
|
if is_composite and live_view is not None
|
|
else msg.get("content"),
|
|
is_composite,
|
|
)
|
|
|
|
@staticmethod
|
|
def _exact_replayed_user_clone_key(
|
|
timestamp: Any, content: Any
|
|
) -> Optional[Tuple[Any, str]]:
|
|
"""Return a hashable key for a column-exact rotation clone."""
|
|
if timestamp is None or content in (None, "", []):
|
|
return None
|
|
try:
|
|
encoded = json.dumps(
|
|
content,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return timestamp, encoded
|
|
|
|
@staticmethod
|
|
def _find_duplicate_replayed_user_message(
|
|
messages: List[Dict[str, Any]], msg: Dict[str, Any]
|
|
) -> Optional[Tuple[int, bool]]:
|
|
"""Return an adjacent replay duplicate and whether *msg* must win.
|
|
|
|
Compression rotation may persist the current ask once in the parent
|
|
and again inside a composite child carrier. Compare the canonical live
|
|
payload for that carrier, while retaining the historical exact-string
|
|
dedupe for ordinary replayed users. The child carrier wins because it
|
|
owns both the current durable row identity and the retained scaffold.
|
|
"""
|
|
if msg.get("role") != "user":
|
|
return None
|
|
|
|
content, prefer_current = SessionDB._canonical_replayed_user_content(msg)
|
|
if content in (None, "", []):
|
|
return None
|
|
|
|
for index in range(len(messages) - 1, -1, -1):
|
|
prev = messages[index]
|
|
if prev.get("role") == "user":
|
|
prev_content, prev_is_composite = (
|
|
SessionDB._canonical_replayed_user_content(prev)
|
|
)
|
|
if prev_content == content and (
|
|
prefer_current
|
|
or prev_is_composite
|
|
or isinstance(content, str)
|
|
):
|
|
return index, prefer_current
|
|
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
|
|
return None
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_duplicate_replayed_user_message(
|
|
messages: List[Dict[str, Any]], msg: Dict[str, Any]
|
|
) -> bool:
|
|
return SessionDB._find_duplicate_replayed_user_message(messages, msg) is not None
|
|
|
|
# =========================================================================
|
|
# Rewind (soft-delete) — see /rewind slash command + issue #21910
|
|
# =========================================================================
|
|
|
|
def get_active_message_ids(self, session_id: str) -> List[int]:
|
|
"""Return the ordered physical ids pinned by rewind CAS checks.
|
|
|
|
Conversation projections intentionally omit legacy background-review
|
|
harness rows. Destructive rewinds must nevertheless pin every active
|
|
physical row so the caller snapshot matches the transaction-local
|
|
comparison in :meth:`rewind_to_message`.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND active = 1 ORDER BY id",
|
|
(session_id,),
|
|
).fetchall()
|
|
return [int(row[0]) for row in rows]
|
|
|
|
@staticmethod
|
|
def _active_transcript_counts(conn, session_id: str) -> tuple[int, int]:
|
|
"""Return active message/tool-call counts inside the caller's txn."""
|
|
rows = conn.execute(
|
|
"SELECT tool_calls FROM messages "
|
|
"WHERE session_id = ? AND active = 1",
|
|
(session_id,),
|
|
).fetchall()
|
|
tool_call_count = 0
|
|
for row in rows:
|
|
raw = row[0]
|
|
if not raw:
|
|
continue
|
|
try:
|
|
decoded = json.loads(raw) if isinstance(raw, str) else raw
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
if isinstance(decoded, list):
|
|
tool_call_count += len(decoded)
|
|
elif decoded:
|
|
tool_call_count += 1
|
|
return len(rows), tool_call_count
|
|
|
|
def rewind_to_message(
|
|
self,
|
|
session_id: str,
|
|
target_message_id: int,
|
|
*,
|
|
preserve_compaction_handoff: bool = False,
|
|
expected_active_ids: Optional[List[int]] = None,
|
|
expected_target_content: Any = None,
|
|
) -> Dict[str, Any]:
|
|
"""Soft-delete all messages with id >= ``target_message_id`` in *session_id*.
|
|
|
|
The target message itself becomes inactive as well so the caller
|
|
can pre-fill it as the next user prompt without it appearing
|
|
twice in the replayed transcript. Rewound rows are kept on
|
|
disk with ``active=0`` for audit / forensic inspection — use
|
|
:meth:`get_messages` with ``include_inactive=True`` to see them.
|
|
|
|
Returns a dict::
|
|
|
|
{
|
|
"rewound_count": int, # number of rows newly flipped to active=0
|
|
"target_message": dict, # full row dict of the target
|
|
"new_head_id": int|None # id of the last still-active row, or None
|
|
}
|
|
|
|
Raises ``ValueError`` if the target message does not exist in
|
|
*session_id* or if its role is not ``"user"``. With
|
|
``preserve_compaction_handoff=True``, a composite summary carrier is
|
|
split inside the same write transaction: its original row is archived
|
|
and its canonical hidden handoff scaffold is inserted as the new head.
|
|
That opt-in result also contains ``replacement_message_id``.
|
|
|
|
``expected_active_ids`` optionally pins the ordered active row set.
|
|
``expected_target_content`` additionally pins the selected canonical
|
|
live-user payload. Both checks run inside the write transaction before
|
|
any row or counter mutation. Presentation-only metadata changes (for
|
|
example Desktop reactions) deliberately do not invalidate a rewind.
|
|
A live cross-process turn lease always refuses the rewind; expired or
|
|
provably dead holders are reclaimed inside the mutation transaction.
|
|
|
|
Always increments ``sessions.rewind_count`` — even when the
|
|
target is already inactive — so the counter accurately reflects
|
|
the number of rewind operations performed against the session.
|
|
Idempotent on the ``active`` flag: re-rewinding past the same
|
|
target is a no-op on row state but still bumps the counter.
|
|
"""
|
|
|
|
def _do(conn):
|
|
# Rewind changes the active transcript and must honor the same
|
|
# compression/closed-parent and cross-process turn guards as
|
|
# append writers.
|
|
self._check_transcript_write_guards(
|
|
conn,
|
|
session_id,
|
|
None,
|
|
reject_active_turn_lease=True,
|
|
reject_active_compression_lock=True,
|
|
)
|
|
|
|
if expected_active_ids is not None:
|
|
active_rows = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND active = 1 ORDER BY id",
|
|
(session_id,),
|
|
).fetchall()
|
|
active_ids = [int(active_row[0]) for active_row in active_rows]
|
|
if active_ids != expected_active_ids:
|
|
raise RuntimeError(
|
|
"active transcript changed before the rewind could be persisted"
|
|
)
|
|
|
|
row = conn.execute(
|
|
"SELECT * FROM messages WHERE id = ? AND session_id = ?",
|
|
(target_message_id, session_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError(
|
|
f"message {target_message_id} not found in session {session_id}"
|
|
)
|
|
target_row = dict(row)
|
|
if target_row.get("role") != "user":
|
|
raise ValueError(
|
|
f"rewind target must be a 'user' message (got role="
|
|
f"{target_row.get('role')!r}, id={target_message_id})"
|
|
)
|
|
|
|
replacement_message_id: Optional[int] = None
|
|
replacement: Optional[Dict[str, Any]] = None
|
|
if preserve_compaction_handoff or expected_target_content is not None:
|
|
if not target_row.get("active"):
|
|
raise ValueError("rewind target is not active")
|
|
from agent.context_compressor import split_user_originated_turn
|
|
|
|
split_target = target_row.copy()
|
|
split_target["content"] = self._decode_content(
|
|
split_target.get("content")
|
|
)
|
|
split_target["display_metadata"] = self._decode_display_metadata(
|
|
split_target.get("display_metadata")
|
|
)
|
|
handoff, live_view = split_user_originated_turn(split_target)
|
|
if live_view is None:
|
|
raise ValueError("rewind target is not a user-originated turn")
|
|
live_content = live_view.get("content")
|
|
if isinstance(live_content, str):
|
|
live_content = sanitize_context(live_content).strip()
|
|
if (
|
|
expected_target_content is not None
|
|
and live_content != expected_target_content
|
|
):
|
|
raise RuntimeError(
|
|
"rewind target changed before it could be persisted"
|
|
)
|
|
if preserve_compaction_handoff and handoff is None:
|
|
raise ValueError(
|
|
"preserve_compaction_handoff requires an active composite carrier"
|
|
)
|
|
replacement = handoff if preserve_compaction_handoff else None
|
|
|
|
cursor = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND id >= ? AND active = 1",
|
|
(session_id, target_message_id),
|
|
)
|
|
ids = [r[0] for r in cursor.fetchall()]
|
|
if ids:
|
|
placeholders = ",".join("?" for _ in ids)
|
|
conn.execute(
|
|
f"UPDATE messages SET active = 0 WHERE id IN ({placeholders})",
|
|
ids,
|
|
)
|
|
if replacement is not None:
|
|
self._insert_message_rows(conn, session_id, [replacement])
|
|
inserted = conn.execute("SELECT last_insert_rowid()").fetchone()
|
|
replacement_message_id = int(inserted[0])
|
|
conn.execute(
|
|
"UPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 "
|
|
"WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
message_count, tool_call_count = self._active_transcript_counts(
|
|
conn, session_id
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = ?, tool_call_count = ? "
|
|
"WHERE id = ?",
|
|
(message_count, tool_call_count, session_id),
|
|
)
|
|
head_row = conn.execute(
|
|
"SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1",
|
|
(session_id,),
|
|
).fetchone()
|
|
new_head_id = (
|
|
head_row[0] if head_row and head_row[0] is not None else None
|
|
)
|
|
return target_row, ids, new_head_id, replacement_message_id
|
|
|
|
target_row, rewound, new_head_id, replacement_message_id = (
|
|
self._execute_write(_do)
|
|
)
|
|
|
|
# Decode content for callers (prefill the prompt buffer) without a
|
|
# second fallible database operation after the transaction commits.
|
|
target_row["content"] = self._decode_content(target_row.get("content"))
|
|
|
|
result = {
|
|
"rewound_count": len(rewound),
|
|
"target_message": target_row,
|
|
"new_head_id": new_head_id,
|
|
}
|
|
if preserve_compaction_handoff:
|
|
result["replacement_message_id"] = replacement_message_id
|
|
return result
|
|
|
|
def restore_rewound(self, session_id: str, since_message_id: int) -> int:
|
|
"""Mark inactive messages with id >= *since_message_id* active again.
|
|
|
|
Returns the number of rows flipped back to ``active=1``.
|
|
Intended for undo-of-rewind and test cleanup; not wired to a
|
|
slash command in v1.
|
|
"""
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"SELECT id FROM messages "
|
|
"WHERE session_id = ? AND id >= ? AND active = 0",
|
|
(session_id, since_message_id),
|
|
)
|
|
ids = [r[0] for r in cursor.fetchall()]
|
|
if ids:
|
|
placeholders = ",".join("?" for _ in ids)
|
|
conn.execute(
|
|
f"UPDATE messages SET active = 1 WHERE id IN ({placeholders})",
|
|
ids,
|
|
)
|
|
return len(ids)
|
|
|
|
return self._execute_write(_do)
|
|
|
|
# =========================================================================
|
|
# 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])
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"{select_with_last_active}"
|
|
f"{where_sql} "
|
|
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
|
|
params,
|
|
)
|
|
return [self._session_row_dict(row) for row in cursor.fetchall()]
|
|
|
|
# =========================================================================
|
|
# 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 ""
|
|
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)
|
|
return cursor.fetchone()[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.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute("SELECT 1 FROM sessions LIMIT ?", (n,))
|
|
rows = cursor.fetchall()
|
|
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}
|
|
|
|
def message_count(self, session_id: str = None) -> int:
|
|
"""Count messages, optionally for a specific session."""
|
|
with self._read_ctx() as conn:
|
|
if session_id:
|
|
cursor = conn.execute(
|
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
|
|
)
|
|
else:
|
|
cursor = conn.execute("SELECT COUNT(*) FROM messages")
|
|
return cursor.fetchone()[0]
|
|
|
|
def has_platform_message_id(
|
|
self, session_id: str, platform_message_id: str
|
|
) -> bool:
|
|
"""Check if a message with the given platform_message_id exists.
|
|
|
|
Uses the idx_messages_platform_msg_id partial index for efficient
|
|
lookup. Used by the gateway's transient-failure dedupe guard (#47237)
|
|
to skip re-persisting a user message that was already saved on a
|
|
prior retry of the same inbound platform message.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT 1 FROM messages "
|
|
"WHERE session_id = ? AND platform_message_id = ? LIMIT 1",
|
|
(session_id, platform_message_id),
|
|
)
|
|
return cursor.fetchone() is not None
|
|
|
|
# =========================================================================
|
|
# Export and cleanup
|
|
# =========================================================================
|
|
|
|
def _is_explicit_fork_child_row(self, session: Dict[str, Any]) -> bool:
|
|
"""True when ``session`` is a branch, delegate, or tool child of its parent.
|
|
|
|
Markers only count as a fork when they point at ``parent_session_id``.
|
|
Compression copies ``model_config`` onto the continuation
|
|
(``publish_compression_child`` callers pass
|
|
``agent._session_init_model_config``), so a delegate's continuation
|
|
carries ``_delegate_from=<the delegate's own parent>``. Presence-only
|
|
matching would treat that real continuation as a fork — the same
|
|
misclassification ``_NON_CONTINUATION_CHILD_FILTER_SQL`` already
|
|
avoids by binding both markers to the queried parent.
|
|
"""
|
|
if session.get("source") == "tool":
|
|
return True
|
|
raw = session.get("model_config")
|
|
if not raw:
|
|
return False
|
|
try:
|
|
cfg = json.loads(raw) if isinstance(raw, str) else raw
|
|
except (TypeError, json.JSONDecodeError):
|
|
return False
|
|
if not isinstance(cfg, dict):
|
|
return False
|
|
parent_id = session.get("parent_session_id")
|
|
branched = cfg.get("_branched_from")
|
|
delegated = cfg.get("_delegate_from")
|
|
if parent_id:
|
|
return branched == parent_id or delegated == parent_id
|
|
return branched is not None or delegated is not None
|
|
|
|
def is_explicit_fork_child(self, session_id: str) -> bool:
|
|
"""True when ``session_id`` is a /branch, delegate, or tool child row.
|
|
|
|
Read-only public view of :meth:`_is_explicit_fork_child_row` for
|
|
callers that must respect the fork boundary without re-implementing
|
|
its marker rules (``agent/prompt_cache_scope.py`` keeps a declared
|
|
conversation key from crossing it). A missing row is not a fork.
|
|
"""
|
|
session = self.get_session(session_id)
|
|
return bool(session and self._is_explicit_fork_child_row(session))
|
|
|
|
def latest_conversation_boundary(
|
|
self, session_key: str, source: str
|
|
) -> Optional[Tuple[int, float]]:
|
|
"""How many conversation boundaries this peer has crossed, and when.
|
|
|
|
A boundary is a row this peer ended at an intentional conversation
|
|
break — the ``_RESET_END_REASONS`` set (``/new``, ``/switch``, idle,
|
|
daily, suspended, resume_pending_expired). That is the same fence
|
|
:meth:`find_latest_gateway_session_for_peer` refuses to reach behind,
|
|
so the two agree on where one conversation stops and the next begins
|
|
and cannot drift.
|
|
|
|
The peer is ``(session_key, source)``, the SAME identity tuple recovery
|
|
uses — never the key alone. ``X-Hermes-Session-Key`` accepts any
|
|
authenticated caller-supplied string, so an API conversation may
|
|
legally carry the same key as a Telegram row in one database; keying
|
|
on the string alone would let a ``/new`` on that unrelated row rotate
|
|
this conversation's affinity identity while recovery correctly refuses
|
|
to cross the same line.
|
|
|
|
Returns ``(count, latest_ended_at)``, or ``None`` when the key has
|
|
never been reset. BOTH halves are reported because each one alone has
|
|
a narrow way to repeat a previous generation:
|
|
|
|
- ``ended_at`` is wall-clock, so a backwards NTP correction between two
|
|
resets writes a SMALLER boundary and ``MAX`` keeps returning the older
|
|
one — the next conversation would reuse the previous generation;
|
|
- ``count`` survives that, but retention pruning of an old ended row
|
|
decrements it.
|
|
|
|
The two fail under different conditions, so the pair only repeats a
|
|
generation if both happen at once. A spurious change is merely one cold
|
|
prompt-cache bucket; a repeated one would put two conversations on the
|
|
same routing key, so the pair is biased toward changing. Read-only;
|
|
``agent/prompt_cache_scope.py`` uses it to keep a host-declared
|
|
conversation key from outliving the conversation it names.
|
|
"""
|
|
if not session_key or not source:
|
|
return None
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT COUNT(*) AS crossings, MAX(ended_at) AS boundary
|
|
FROM sessions
|
|
WHERE session_key = ?
|
|
AND source = ?
|
|
AND ended_at IS NOT NULL
|
|
AND end_reason IN ({_RESET_END_REASONS_SQL})
|
|
""",
|
|
(session_key, source),
|
|
).fetchone()
|
|
boundary = row["boundary"] if row is not None else None
|
|
if boundary is None:
|
|
return None
|
|
return int(row["crossings"] or 0), float(boundary)
|
|
|
|
def _is_compression_child_row(self, child: Dict[str, Any]) -> bool:
|
|
parent_id = child.get("parent_session_id")
|
|
if not parent_id or self._is_explicit_fork_child_row(child):
|
|
return False
|
|
parent = self.get_session(parent_id)
|
|
return bool(parent and parent.get("end_reason") == "compression")
|
|
|
|
def get_compression_lineage(self, session_id: str) -> List[str]:
|
|
"""Return compression ancestors through tip in chronological order."""
|
|
session = self.get_session(session_id)
|
|
if not session or self._is_explicit_fork_child_row(session):
|
|
return [session_id] if session else []
|
|
|
|
root = session
|
|
ancestors = {root["id"]}
|
|
while self._is_compression_child_row(root):
|
|
parent = self.get_session(root["parent_session_id"])
|
|
if not parent or parent["id"] in ancestors:
|
|
break
|
|
root = parent
|
|
ancestors.add(root["id"])
|
|
|
|
lineage = [root["id"]]
|
|
seen = {root["id"]}
|
|
current = root
|
|
while current.get("end_reason") == "compression":
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM sessions
|
|
WHERE parent_session_id = ?
|
|
ORDER BY started_at ASC
|
|
""",
|
|
(current["id"],),
|
|
).fetchall()
|
|
next_child = None
|
|
for row in rows:
|
|
candidate = dict(row)
|
|
if self._is_compression_child_row(candidate):
|
|
next_child = candidate
|
|
break
|
|
if not next_child or next_child["id"] in seen:
|
|
break
|
|
lineage.append(next_child["id"])
|
|
seen.add(next_child["id"])
|
|
current = next_child
|
|
if current["id"] == session_id:
|
|
# Continue to include later compression tips only when the
|
|
# requested session itself was compacted.
|
|
continue
|
|
return lineage if session_id in lineage else [session_id]
|
|
|
|
def clear_messages(self, session_id: str) -> None:
|
|
"""Delete all messages for a session and reset its counters."""
|
|
def _do(conn):
|
|
conn.execute(
|
|
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
@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.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"SELECT COUNT(*) FROM sessions WHERE {self._EMPTY_SESSION_WHERE}"
|
|
)
|
|
return cursor.fetchone()[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
|
|
|
|
@staticmethod
|
|
def _prune_filter_where(
|
|
*,
|
|
last_active_before: Optional[float] = None,
|
|
last_active_after: Optional[float] = None,
|
|
started_before: Optional[float] = None,
|
|
started_after: Optional[float] = None,
|
|
source: Optional[str] = None,
|
|
title_like: Optional[str] = None,
|
|
end_reason: Optional[str] = None,
|
|
cwd_prefix: Optional[str] = None,
|
|
min_messages: Optional[int] = None,
|
|
max_messages: Optional[int] = None,
|
|
archived: Optional[bool] = None,
|
|
model_like: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
chat_id: Optional[str] = None,
|
|
chat_type: Optional[str] = None,
|
|
branch_like: Optional[str] = None,
|
|
min_tokens: Optional[int] = None,
|
|
max_tokens: Optional[int] = None,
|
|
min_cost: Optional[float] = None,
|
|
max_cost: Optional[float] = None,
|
|
min_tool_calls: Optional[int] = None,
|
|
max_tool_calls: Optional[int] = None,
|
|
include_pinned: bool = False,
|
|
) -> Tuple[str, list]:
|
|
"""Build the shared WHERE clause for bulk prune/archive selection.
|
|
|
|
All filters AND together. Only ended sessions are ever candidates
|
|
(``ended_at IS NOT NULL``) so a live session is never selected.
|
|
``archived`` is a tri-state: ``None`` = both, ``True`` = only
|
|
archived rows, ``False`` = only unarchived rows.
|
|
|
|
String matching conventions: ``model_like`` / ``branch_like`` /
|
|
``title_like`` are case-insensitive substring matches (model slugs
|
|
and branch names vary in prefix format); ``provider`` / ``user_id``
|
|
/ ``chat_id`` / ``chat_type`` / ``source`` / ``end_reason`` are
|
|
exact (case-insensitive for provider). Token bounds apply to
|
|
``input_tokens + output_tokens``; cost bounds apply to
|
|
``COALESCE(actual_cost_usd, estimated_cost_usd)``.
|
|
|
|
The clause references the ``s`` table alias — callers must select
|
|
``FROM sessions s``.
|
|
"""
|
|
clauses = ["s.ended_at IS NOT NULL"]
|
|
params: list = []
|
|
if last_active_before is not None:
|
|
clauses.append(
|
|
"""COALESCE(
|
|
(SELECT MAX(m.timestamp) FROM messages m
|
|
WHERE m.session_id = s.id),
|
|
s.started_at
|
|
) < ?"""
|
|
)
|
|
params.append(last_active_before)
|
|
if last_active_after is not None:
|
|
clauses.append(
|
|
"""COALESCE(
|
|
(SELECT MAX(m.timestamp) FROM messages m
|
|
WHERE m.session_id = s.id),
|
|
s.started_at
|
|
) >= ?"""
|
|
)
|
|
params.append(last_active_after)
|
|
if started_before is not None:
|
|
clauses.append("s.started_at < ?")
|
|
params.append(started_before)
|
|
if started_after is not None:
|
|
clauses.append("s.started_at >= ?")
|
|
params.append(started_after)
|
|
if source:
|
|
clauses.append("s.source = ?")
|
|
params.append(source)
|
|
if title_like:
|
|
clauses.append("LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\'")
|
|
params.append(f"%{_escape_like(title_like.lower())}%")
|
|
if end_reason:
|
|
clauses.append("s.end_reason = ?")
|
|
params.append(end_reason)
|
|
if cwd_prefix:
|
|
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
|
|
clauses.append(clause)
|
|
params.extend(clause_params)
|
|
if min_messages is not None:
|
|
clauses.append("s.message_count >= ?")
|
|
params.append(min_messages)
|
|
if max_messages is not None:
|
|
clauses.append("s.message_count <= ?")
|
|
params.append(max_messages)
|
|
if model_like:
|
|
clauses.append("LOWER(COALESCE(s.model, '')) LIKE ? ESCAPE '\\'")
|
|
params.append(f"%{_escape_like(model_like.lower())}%")
|
|
if provider:
|
|
clauses.append("LOWER(COALESCE(s.billing_provider, '')) = ?")
|
|
params.append(provider.lower())
|
|
if user_id:
|
|
clauses.append("s.user_id = ?")
|
|
params.append(user_id)
|
|
if chat_id:
|
|
clauses.append("s.chat_id = ?")
|
|
params.append(chat_id)
|
|
if chat_type:
|
|
clauses.append("s.chat_type = ?")
|
|
params.append(chat_type)
|
|
if branch_like:
|
|
clauses.append("LOWER(COALESCE(s.git_branch, '')) LIKE ? ESCAPE '\\'")
|
|
params.append(f"%{_escape_like(branch_like.lower())}%")
|
|
if min_tokens is not None:
|
|
clauses.append(
|
|
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) >= ?"
|
|
)
|
|
params.append(min_tokens)
|
|
if max_tokens is not None:
|
|
clauses.append(
|
|
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) <= ?"
|
|
)
|
|
params.append(max_tokens)
|
|
if min_cost is not None:
|
|
clauses.append(
|
|
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) >= ?"
|
|
)
|
|
params.append(min_cost)
|
|
if max_cost is not None:
|
|
clauses.append(
|
|
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) <= ?"
|
|
)
|
|
params.append(max_cost)
|
|
if min_tool_calls is not None:
|
|
clauses.append("COALESCE(s.tool_call_count, 0) >= ?")
|
|
params.append(min_tool_calls)
|
|
if max_tool_calls is not None:
|
|
clauses.append("COALESCE(s.tool_call_count, 0) <= ?")
|
|
params.append(max_tool_calls)
|
|
if archived is True:
|
|
clauses.append("s.archived = 1")
|
|
elif archived is False:
|
|
clauses.append("s.archived = 0")
|
|
# Pinned sessions are a durable "keep" flag (exempt from the stale
|
|
# auto-archive sweep). Bulk prune/delete/archive must honor that too:
|
|
# exclude pinned rows unless the caller explicitly opts in. Without
|
|
# this, `sessions prune`/`delete`/`archive` with a filter silently
|
|
# destroyed pinned conversations (round-3 QA SES-01, data loss).
|
|
if not include_pinned:
|
|
clauses.append("COALESCE(s.pinned, 0) = 0")
|
|
return " AND ".join(clauses), params
|
|
|
|
@staticmethod
|
|
def _apply_prune_age_filter(
|
|
older_than_days: Optional[float], filters: Dict[str, Any]
|
|
) -> None:
|
|
"""Translate the legacy age window into the shared activity filter."""
|
|
if (
|
|
filters.get("last_active_before") is None
|
|
and filters.get("started_before") is None
|
|
and older_than_days is not None
|
|
):
|
|
filters["last_active_before"] = time.time() - (
|
|
older_than_days * 86400
|
|
)
|
|
|
|
def list_prune_candidates(
|
|
self,
|
|
older_than_days: Optional[float] = None,
|
|
source: str = None,
|
|
**filters,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Return the sessions a matching :meth:`prune_sessions` /
|
|
:meth:`archive_sessions` call would touch, without modifying anything.
|
|
|
|
Backs ``--dry-run`` and pre-confirmation counts. Accepts the same
|
|
keyword filters as :meth:`_prune_filter_where` (unknown names raise
|
|
``TypeError`` there). Rows are ordered oldest-first and carry
|
|
``id, source, title, model, started_at, last_active, ended_at,
|
|
message_count, archived``. ``older_than_days`` is an inactivity
|
|
threshold: it uses the latest message timestamp, falling back to
|
|
``started_at`` for sessions without messages.
|
|
"""
|
|
self._apply_prune_age_filter(older_than_days, filters)
|
|
where, params = self._prune_filter_where(source=source, **filters)
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"""SELECT s.id, s.source, s.title, s.model, s.started_at,
|
|
COALESCE(
|
|
(SELECT MAX(m.timestamp) FROM messages m
|
|
WHERE m.session_id = s.id),
|
|
s.started_at
|
|
) AS last_active,
|
|
s.ended_at, s.message_count, s.archived
|
|
FROM sessions s WHERE {where}
|
|
ORDER BY last_active ASC, s.started_at ASC""",
|
|
params,
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def count_prune_matches(
|
|
self,
|
|
older_than_days: Optional[float] = None,
|
|
source: str = None,
|
|
**filters,
|
|
) -> int:
|
|
"""Count sessions a matching prune/archive would touch.
|
|
|
|
Same filter surface as :meth:`list_prune_candidates` (including the
|
|
``include_pinned`` tri-state), but returns only a count. Used by the
|
|
CLI to report how many pinned sessions are being spared.
|
|
"""
|
|
self._apply_prune_age_filter(older_than_days, filters)
|
|
where, params = self._prune_filter_where(source=source, **filters)
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"SELECT COUNT(*) FROM sessions s WHERE {where}", params
|
|
)
|
|
return int(cursor.fetchone()[0])
|
|
|
|
def count_open_prune_matches(
|
|
self,
|
|
older_than_days: Optional[float] = None,
|
|
source: str = None,
|
|
**filters,
|
|
) -> int:
|
|
"""Count open sessions excluded from a matching bulk prune.
|
|
|
|
This applies every normal prune filter, but inverts only the
|
|
``ended_at`` safety guard. It is visibility-only: callers can explain
|
|
why an otherwise matching session was skipped without making live
|
|
sessions eligible for destructive pruning.
|
|
"""
|
|
self._apply_prune_age_filter(older_than_days, filters)
|
|
where, params = self._prune_filter_where(source=source, **filters)
|
|
ended_guard = "s.ended_at IS NOT NULL"
|
|
if not where.startswith(ended_guard):
|
|
raise RuntimeError("prune filter lost its ended-session safety guard")
|
|
open_where = f"s.ended_at IS NULL{where[len(ended_guard):]}"
|
|
with self._read_ctx() as conn:
|
|
cursor = conn.execute(
|
|
f"SELECT COUNT(*) FROM sessions s WHERE {open_where}", params
|
|
)
|
|
return int(cursor.fetchone()[0])
|
|
|
|
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)
|
|
|
|
def archive_stale_sessions(
|
|
self, idle_days: float, *, exclude_pinned: bool = True
|
|
) -> int:
|
|
"""Archive every session untouched for at least ``idle_days`` days.
|
|
|
|
"Touched" is the freshest of ``last_activity_at`` and the latest
|
|
message timestamp (else ``started_at``) — i.e. real recency, not
|
|
creation time — so a session
|
|
created long ago but active yesterday is spared, while an old
|
|
abandoned one (even a still-open one) is swept. Unlike
|
|
:meth:`archive_sessions`, this method can also archive unended
|
|
sessions.
|
|
|
|
Guards:
|
|
* ``pinned = 0`` when ``exclude_pinned`` (the Desktop "keep" flag).
|
|
* ``archived = 0`` so repeat runs are idempotent no-ops.
|
|
* only lineage *tips* / standalone rows are candidates
|
|
(``end_reason <> 'compression'``); a stale tip archives its whole
|
|
chain via :meth:`set_session_archived`, so we never resurrect an
|
|
active conversation by matching an old compressed-away root whose
|
|
live continuation is recent.
|
|
|
|
Returns the number of sessions archived. Never raises for an empty or
|
|
non-positive ``idle_days`` — it simply archives nothing.
|
|
"""
|
|
if idle_days is None or idle_days < 0:
|
|
return 0
|
|
cutoff = time.time() - float(idle_days) * 86400.0
|
|
pin_clause = "AND s.pinned = 0" if exclude_pinned else ""
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT s.id FROM sessions s
|
|
WHERE s.archived = 0
|
|
AND COALESCE(s.end_reason, '') <> 'compression'
|
|
{pin_clause}
|
|
AND {_sql_session_last_active("s")} < ?
|
|
ORDER BY s.started_at ASC
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
ids = [(r["id"] if isinstance(r, sqlite3.Row) else r[0]) for r in rows]
|
|
for sid in ids:
|
|
self.set_session_archived(sid, True)
|
|
return len(ids)
|
|
|
|
def prune_sessions(
|
|
self,
|
|
older_than_days: Optional[float] = 90,
|
|
source: str = None,
|
|
sessions_dir: Optional[Path] = None,
|
|
**filters,
|
|
) -> int:
|
|
"""Delete sessions matching the filters. Returns count deleted.
|
|
|
|
By default, delete ended sessions inactive for
|
|
``older_than_days`` days, optionally restricted to ``source``.
|
|
Activity is the latest message timestamp, falling back to
|
|
``started_at`` for sessions without messages. Additional keyword
|
|
filters AND together — the full set is defined by
|
|
:meth:`_prune_filter_where`:
|
|
|
|
* ``last_active_before`` / ``last_active_after`` — epoch bounds on
|
|
the latest message timestamp (falling back to ``started_at``).
|
|
* ``started_before`` / ``started_after`` — epoch bounds on
|
|
``started_at``. An explicit ``started_before`` overrides the
|
|
default ``older_than_days`` inactivity cutoff; pass
|
|
``older_than_days=None`` for no implicit upper age bound.
|
|
* ``title_like`` / ``model_like`` / ``branch_like`` —
|
|
case-insensitive substring matches.
|
|
* ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` /
|
|
``chat_type`` — exact matches (provider case-insensitive, against
|
|
``billing_provider``).
|
|
* ``cwd_prefix`` — session cwd equals or is under this path.
|
|
* ``min_messages`` / ``max_messages`` — bounds on message_count.
|
|
* ``min_tokens`` / ``max_tokens`` — bounds on input+output tokens.
|
|
* ``min_cost`` / ``max_cost`` — bounds on USD cost
|
|
(actual, falling back to estimated).
|
|
* ``min_tool_calls`` / ``max_tool_calls`` — bounds on tool_call_count.
|
|
* ``archived`` — tri-state: None = both (default), True = only
|
|
archived, False = only unarchived.
|
|
|
|
Only prunes ended sessions (not active ones). Child sessions outside
|
|
the prune window are orphaned (parent_session_id set to NULL) rather
|
|
than cascade-deleted. When *sessions_dir* is provided, also removes
|
|
on-disk transcript files (``.json`` / ``.jsonl`` /
|
|
``request_dump_*``) for every pruned session, outside the DB
|
|
transaction.
|
|
"""
|
|
self._apply_prune_age_filter(older_than_days, filters)
|
|
where, where_params = self._prune_filter_where(source=source, **filters)
|
|
removed_ids: list[str] = []
|
|
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
f"SELECT s.id FROM sessions s WHERE {where}", where_params
|
|
)
|
|
session_ids = {row["id"] for row in cursor.fetchall()}
|
|
|
|
if not session_ids:
|
|
return 0
|
|
|
|
# Orphan any sessions whose parent is about to be deleted
|
|
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:
|
|
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)
|
|
# Clean up on-disk files outside the DB transaction
|
|
for sid in removed_ids:
|
|
self._remove_session_files(sessions_dir, sid)
|
|
return count
|
|
|
|
def purge_stale_tool_call_markers(
|
|
self, *, dry_run: bool = False, backup: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""Permanently clear bare tool-call marker content (e.g. "[memory]")
|
|
left in the ``messages`` table by sessions persisted before the
|
|
#78148 fix in ``agent.conversation_loop``.
|
|
|
|
``_strip_stale_tool_call_markers`` already repairs this in memory on
|
|
every session load (see ``_rows_to_conversation``), so running this
|
|
is optional — but for long-lived sessions the same rows get
|
|
re-scanned and re-repaired on every resume, which is wasted work
|
|
and keeps the contaminated bytes sitting in the DB (and in any
|
|
downstream cache/backup snapshot of it) indefinitely. This rewrites
|
|
the affected rows once, in place.
|
|
|
|
Only the ``content`` column is touched — ``role``, ``tool_calls``,
|
|
and every other column on the row are left exactly as they are, so
|
|
provider tool_call/tool_result pairing is unaffected.
|
|
|
|
Unlike the in-memory repair, this UPDATE is permanent and can't be
|
|
undone from within the DB. Since ``backup`` defaults to True, a
|
|
timestamped full snapshot is taken via ``VACUUM INTO`` (safe against
|
|
a live connection, unlike the raw-copy ``_backup_db_file`` used for
|
|
malformed-schema repair) before any row is touched — mirroring
|
|
``repair_state_db_schema``'s backup-by-default convention for
|
|
destructive state.db operations. No snapshot is taken when there is
|
|
nothing to change.
|
|
|
|
With ``dry_run=True``, reports the affected row count/ids without
|
|
writing or backing up (read-only, no write lock taken).
|
|
|
|
Returns ``{"dry_run": bool, "rows_affected": int, "row_ids": [...],
|
|
"backup_path": str|None}``.
|
|
"""
|
|
|
|
def _find_affected(conn) -> List[int]:
|
|
cursor = conn.execute(
|
|
"SELECT id, content FROM messages "
|
|
"WHERE role = 'assistant' AND tool_calls IS NOT NULL AND tool_calls != ''"
|
|
)
|
|
affected: List[int] = []
|
|
for row in cursor.fetchall():
|
|
content = row["content"]
|
|
if isinstance(content, str) and _STALE_TOOL_CALL_MARKER_RE.fullmatch(content.strip()):
|
|
affected.append(row["id"])
|
|
return affected
|
|
|
|
with self._read_ctx() as conn:
|
|
affected_ids = _find_affected(conn)
|
|
|
|
if dry_run:
|
|
return {
|
|
"dry_run": True,
|
|
"rows_affected": len(affected_ids),
|
|
"row_ids": affected_ids,
|
|
"backup_path": None,
|
|
}
|
|
|
|
if not affected_ids:
|
|
return {
|
|
"dry_run": False,
|
|
"rows_affected": 0,
|
|
"row_ids": [],
|
|
"backup_path": None,
|
|
}
|
|
|
|
backup_path: Optional[str] = None
|
|
if backup:
|
|
import datetime
|
|
|
|
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
dest = self.db_path.with_name(
|
|
f"{self.db_path.name}.pre-clean-markers-backup-{stamp}"
|
|
)
|
|
with self._lock:
|
|
self._conn.execute("VACUUM INTO ?", (str(dest),))
|
|
backup_path = str(dest)
|
|
logger.info("Backed up state.db to %s before clean-markers write", backup_path)
|
|
|
|
def _do(conn):
|
|
ids = _find_affected(conn)
|
|
if ids:
|
|
placeholders = ",".join("?" * len(ids))
|
|
conn.execute(
|
|
f"UPDATE messages SET content = '' WHERE id IN ({placeholders})",
|
|
ids,
|
|
)
|
|
return ids
|
|
|
|
affected_ids = self._execute_write(_do)
|
|
if affected_ids:
|
|
logger.info(
|
|
"Permanently cleared %d stale tool-call marker row(s) in state.db (#78148)",
|
|
len(affected_ids),
|
|
)
|
|
return {
|
|
"dry_run": False,
|
|
"rows_affected": len(affected_ids),
|
|
"row_ids": affected_ids,
|
|
"backup_path": backup_path,
|
|
}
|
|
|
|
# ── 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["value"] if isinstance(row, sqlite3.Row) else 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
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(key, value),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
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("_", "\\_")
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"SELECT key, value FROM state_meta WHERE key LIKE ? ESCAPE '\\'",
|
|
(escaped + "%",),
|
|
).fetchall()
|
|
return [(row[0], row[1]) for row in rows]
|
|
|
|
def apply_telegram_topic_migration(self) -> None:
|
|
"""Create Telegram DM topic-mode tables on explicit /topic opt-in.
|
|
|
|
This migration is deliberately not part of automatic SessionDB startup
|
|
reconciliation. Operators must be able to upgrade Hermes, keep the old
|
|
Telegram bot behavior running, and only mutate topic-mode state when the
|
|
user executes /topic to opt into the feature.
|
|
|
|
Schema versions:
|
|
v1 — initial shape (no ON DELETE CASCADE on session_id FK)
|
|
v2 — session_id FK gets ON DELETE CASCADE so session pruning
|
|
automatically clears bindings.
|
|
"""
|
|
def _do(conn):
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode (
|
|
chat_id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
activated_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
has_topics_enabled INTEGER,
|
|
allows_users_to_create_topics INTEGER,
|
|
capability_checked_at REAL,
|
|
intro_message_id TEXT,
|
|
pinned_message_id TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings (
|
|
chat_id TEXT NOT NULL,
|
|
thread_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
session_key TEXT NOT NULL,
|
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
managed_mode TEXT NOT NULL DEFAULT 'auto',
|
|
linked_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (chat_id, thread_id)
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
|
|
ON telegram_dm_topic_bindings(session_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
|
|
ON telegram_dm_topic_bindings(user_id, chat_id);
|
|
"""
|
|
)
|
|
|
|
# v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK
|
|
# lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we
|
|
# rebuild the table. Only runs once per DB (version gate).
|
|
current = conn.execute(
|
|
"SELECT value FROM state_meta WHERE key = ?",
|
|
("telegram_dm_topic_schema_version",),
|
|
).fetchone()
|
|
current_version = int(current[0]) if current and str(current[0]).isdigit() else 0
|
|
if current_version < 2:
|
|
fk_rows = conn.execute(
|
|
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
|
|
).fetchall()
|
|
needs_rebuild = any(
|
|
row[2] == "sessions" and (row[6] or "") != "CASCADE"
|
|
for row in fk_rows
|
|
)
|
|
if needs_rebuild:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE telegram_dm_topic_bindings_new (
|
|
chat_id TEXT NOT NULL,
|
|
thread_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
session_key TEXT NOT NULL,
|
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
managed_mode TEXT NOT NULL DEFAULT 'auto',
|
|
linked_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (chat_id, thread_id)
|
|
);
|
|
INSERT INTO telegram_dm_topic_bindings_new
|
|
SELECT chat_id, thread_id, user_id, session_key,
|
|
session_id, managed_mode, linked_at, updated_at
|
|
FROM telegram_dm_topic_bindings;
|
|
DROP TABLE telegram_dm_topic_bindings;
|
|
ALTER TABLE telegram_dm_topic_bindings_new
|
|
RENAME TO telegram_dm_topic_bindings;
|
|
CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session
|
|
ON telegram_dm_topic_bindings(session_id);
|
|
CREATE INDEX idx_telegram_dm_topic_bindings_user
|
|
ON telegram_dm_topic_bindings(user_id, chat_id);
|
|
"""
|
|
)
|
|
|
|
conn.execute(
|
|
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
("telegram_dm_topic_schema_version", "2"),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def enable_telegram_topic_mode(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
user_id: str,
|
|
has_topics_enabled: Optional[bool] = None,
|
|
allows_users_to_create_topics: Optional[bool] = None,
|
|
) -> None:
|
|
"""Enable Telegram DM topic mode for one private chat/user.
|
|
|
|
This method intentionally owns the explicit topic migration. Ordinary
|
|
SessionDB startup must not create these side tables.
|
|
"""
|
|
self.apply_telegram_topic_migration()
|
|
now = time.time()
|
|
|
|
def _to_int(value: Optional[bool]) -> Optional[int]:
|
|
if value is None:
|
|
return None
|
|
return 1 if value else 0
|
|
|
|
def _do(conn):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO telegram_dm_topic_mode (
|
|
chat_id, user_id, enabled, activated_at, updated_at,
|
|
has_topics_enabled, allows_users_to_create_topics,
|
|
capability_checked_at
|
|
) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(chat_id) DO UPDATE SET
|
|
user_id = excluded.user_id,
|
|
enabled = 1,
|
|
updated_at = excluded.updated_at,
|
|
has_topics_enabled = excluded.has_topics_enabled,
|
|
allows_users_to_create_topics = excluded.allows_users_to_create_topics,
|
|
capability_checked_at = excluded.capability_checked_at
|
|
""",
|
|
(
|
|
str(chat_id),
|
|
str(user_id),
|
|
now,
|
|
now,
|
|
_to_int(has_topics_enabled),
|
|
_to_int(allows_users_to_create_topics),
|
|
now,
|
|
),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def disable_telegram_topic_mode(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
clear_bindings: bool = True,
|
|
) -> None:
|
|
"""Disable Telegram DM topic mode for one private chat.
|
|
|
|
When ``clear_bindings`` is True (default) the (chat_id, thread_id)
|
|
bindings for this chat are also cleared so re-enabling later
|
|
starts from a clean slate. Set to False if the operator wants to
|
|
preserve bindings for a later re-enable.
|
|
|
|
Never creates the topic-mode tables from scratch; if they don't
|
|
exist there is nothing to disable and the call is a no-op.
|
|
"""
|
|
def _do(conn):
|
|
try:
|
|
conn.execute(
|
|
"UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
|
|
"WHERE chat_id = ?",
|
|
(time.time(), str(chat_id)),
|
|
)
|
|
if clear_bindings:
|
|
conn.execute(
|
|
"DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?",
|
|
(str(chat_id),),
|
|
)
|
|
except sqlite3.OperationalError:
|
|
# Tables don't exist yet — nothing to disable.
|
|
return
|
|
self._execute_write(_do)
|
|
|
|
def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool:
|
|
"""Return whether Telegram DM topic mode is enabled for this chat/user."""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT enabled FROM telegram_dm_topic_mode
|
|
WHERE chat_id = ? AND user_id = ?
|
|
""",
|
|
(str(chat_id), str(user_id)),
|
|
).fetchone()
|
|
except sqlite3.OperationalError:
|
|
return False
|
|
if row is None:
|
|
return False
|
|
enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0]
|
|
return bool(enabled)
|
|
|
|
def get_telegram_topic_binding(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
thread_id: str,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Return the session binding for a Telegram DM topic, if present."""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT * FROM telegram_dm_topic_bindings
|
|
WHERE chat_id = ? AND thread_id = ?
|
|
""",
|
|
(str(chat_id), str(thread_id)),
|
|
).fetchone()
|
|
except sqlite3.OperationalError:
|
|
return None
|
|
return dict(row) if row else None
|
|
|
|
def list_telegram_topic_bindings_for_chat(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
) -> List[Dict[str, Any]]:
|
|
"""All Telegram DM topic bindings for one chat, newest first.
|
|
|
|
Read-only; returns [] if the bindings table doesn't exist yet
|
|
(does not trigger the topic-mode migration).
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT * FROM telegram_dm_topic_bindings "
|
|
"WHERE chat_id = ? ORDER BY updated_at DESC",
|
|
(str(chat_id),),
|
|
).fetchall()
|
|
except sqlite3.OperationalError:
|
|
return []
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_telegram_topic_binding_by_session(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Return the Telegram DM topic binding for a given session_id, if present.
|
|
|
|
Uses the UNIQUE INDEX on telegram_dm_topic_bindings(session_id) for an
|
|
efficient reverse lookup. Returns None when the session has no binding or
|
|
the table does not exist yet.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT * FROM telegram_dm_topic_bindings
|
|
WHERE session_id = ?
|
|
""",
|
|
(str(session_id),),
|
|
).fetchone()
|
|
except sqlite3.OperationalError:
|
|
return None
|
|
return dict(row) if row else None
|
|
|
|
def delete_telegram_topic_binding(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
thread_id: str,
|
|
) -> int:
|
|
"""Remove the binding row for a single (chat, thread) pair.
|
|
|
|
Called when the Telegram Bot API confirms a topic was deleted
|
|
externally (``Thread not found`` after the same-thread retry
|
|
already failed). Without this prune, the stale row keeps
|
|
living in ``telegram_dm_topic_bindings`` and the
|
|
recovery logic in ``gateway.run._recover_telegram_topic_thread_id``
|
|
cheerfully redirects future inbound messages to the deleted
|
|
topic, causing tool progress, approvals, and replies to land
|
|
in the wrong place. Issue #31501.
|
|
|
|
When this prune removes the chat's *last* remaining binding,
|
|
the chat's row in ``telegram_dm_topic_mode`` is also flipped to
|
|
``enabled = 0`` in the same transaction. Otherwise the chat
|
|
would be left in topic mode with zero lanes — and
|
|
``gateway.run._recover_telegram_topic_thread_id`` keeps treating
|
|
the chat as topic-enabled, lobby messages keep hunting for a
|
|
binding that no longer exists, and a user who disabled topics in
|
|
the Telegram client (rather than via ``/topic off``) stays stuck
|
|
until the next send happens to fail. Clearing the flag makes
|
|
recovery fully stand down once the dead topics are gone.
|
|
|
|
Returns the number of binding rows deleted (0 when the binding
|
|
was already absent or the topic-mode tables haven't been
|
|
migrated yet — both are silent no-ops; we never raise from
|
|
a cleanup hot path).
|
|
"""
|
|
chat_id = str(chat_id)
|
|
thread_id = str(thread_id)
|
|
deleted = {"count": 0}
|
|
|
|
def _do(conn):
|
|
try:
|
|
cursor = conn.execute(
|
|
"""
|
|
DELETE FROM telegram_dm_topic_bindings
|
|
WHERE chat_id = ? AND thread_id = ?
|
|
""",
|
|
(chat_id, thread_id),
|
|
)
|
|
deleted["count"] = cursor.rowcount or 0
|
|
except sqlite3.OperationalError:
|
|
# Tables don't exist yet — nothing to prune.
|
|
deleted["count"] = 0
|
|
return
|
|
if not deleted["count"]:
|
|
return
|
|
# If that was the chat's last binding, disable topic mode for
|
|
# the chat so recovery stops steering lobby messages at a now
|
|
# empty lane set. Same transaction → no read-after-prune race.
|
|
try:
|
|
remaining = conn.execute(
|
|
"""
|
|
SELECT 1 FROM telegram_dm_topic_bindings
|
|
WHERE chat_id = ? LIMIT 1
|
|
""",
|
|
(chat_id,),
|
|
).fetchone()
|
|
if remaining is None:
|
|
conn.execute(
|
|
"UPDATE telegram_dm_topic_mode "
|
|
"SET enabled = 0, updated_at = ? WHERE chat_id = ?",
|
|
(time.time(), chat_id),
|
|
)
|
|
except sqlite3.OperationalError:
|
|
# telegram_dm_topic_mode absent — binding prune still stands.
|
|
pass
|
|
|
|
self._execute_write(_do)
|
|
return deleted["count"]
|
|
|
|
def bind_telegram_topic(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
thread_id: str,
|
|
user_id: str,
|
|
session_key: str,
|
|
session_id: str,
|
|
managed_mode: str = "auto",
|
|
) -> None:
|
|
"""Bind one Telegram DM topic thread to one Hermes session.
|
|
|
|
A Hermes session may only be linked to one Telegram topic in MVP.
|
|
Rebinding the same topic to the same session is idempotent; trying to
|
|
link the same session to a different topic raises ValueError.
|
|
"""
|
|
self.apply_telegram_topic_migration()
|
|
now = time.time()
|
|
chat_id = str(chat_id)
|
|
thread_id = str(thread_id)
|
|
user_id = str(user_id)
|
|
session_key = str(session_key)
|
|
session_id = str(session_id)
|
|
|
|
def _do(conn):
|
|
existing_session = conn.execute(
|
|
"""
|
|
SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
|
|
WHERE session_id = ?
|
|
""",
|
|
(session_id,),
|
|
).fetchone()
|
|
if existing_session is not None:
|
|
linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0]
|
|
linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1]
|
|
if str(linked_chat) != chat_id or str(linked_thread) != thread_id:
|
|
raise ValueError("session is already linked to another Telegram topic")
|
|
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO telegram_dm_topic_bindings (
|
|
chat_id, thread_id, user_id, session_key, session_id,
|
|
managed_mode, linked_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(chat_id, thread_id) DO UPDATE SET
|
|
user_id = excluded.user_id,
|
|
session_key = excluded.session_key,
|
|
session_id = excluded.session_id,
|
|
managed_mode = excluded.managed_mode,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(
|
|
chat_id,
|
|
thread_id,
|
|
user_id,
|
|
session_key,
|
|
session_id,
|
|
managed_mode,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
|
|
"""Return True if a Hermes session is already bound to any Telegram DM topic.
|
|
|
|
Read-only: does NOT trigger the telegram-topic migration. If the
|
|
topic-mode tables have not been created yet (i.e. nobody has run
|
|
``/topic`` in this profile), the session is by definition unbound
|
|
and we return False.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT 1 FROM telegram_dm_topic_bindings
|
|
WHERE session_id = ?
|
|
LIMIT 1
|
|
""",
|
|
(str(session_id),),
|
|
).fetchone()
|
|
except sqlite3.OperationalError:
|
|
return False
|
|
return row is not None
|
|
|
|
def list_unlinked_telegram_sessions_for_user(
|
|
self,
|
|
*,
|
|
chat_id: str,
|
|
user_id: str,
|
|
limit: int = 10,
|
|
) -> List[Dict[str, Any]]:
|
|
"""List previous Telegram sessions for this user that are not bound to a topic.
|
|
|
|
Read-only: does NOT trigger the telegram-topic migration. If the
|
|
topic-mode tables are absent, fall back to a simpler query that
|
|
just returns this user's Telegram sessions — there can't be any
|
|
bindings yet.
|
|
"""
|
|
with self._read_ctx() as conn:
|
|
try:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT s.*,
|
|
COALESCE(sp.prompt, s.system_prompt)
|
|
AS _system_prompt_resolved,
|
|
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
|
|
LEFT JOIN system_prompts sp
|
|
ON sp.hash = s.system_prompt_hash
|
|
WHERE s.source = 'telegram'
|
|
AND s.user_id = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM telegram_dm_topic_bindings b
|
|
WHERE b.session_id = s.id
|
|
)
|
|
ORDER BY last_active DESC, s.started_at DESC
|
|
LIMIT ?
|
|
""",
|
|
(str(user_id), int(limit)),
|
|
).fetchall()
|
|
except sqlite3.OperationalError:
|
|
# telegram_dm_topic_bindings doesn't exist yet — no bindings
|
|
# means every telegram session for this user is "unlinked".
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT s.*,
|
|
COALESCE(sp.prompt, s.system_prompt)
|
|
AS _system_prompt_resolved,
|
|
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
|
|
LEFT JOIN system_prompts sp
|
|
ON sp.hash = s.system_prompt_hash
|
|
WHERE s.source = 'telegram'
|
|
AND s.user_id = ?
|
|
ORDER BY last_active DESC, s.started_at DESC
|
|
LIMIT ?
|
|
""",
|
|
(str(user_id), int(limit)),
|
|
).fetchall()
|
|
|
|
sessions: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
session = self._session_row_dict(row)
|
|
session["preview"] = _shape_preview(session.pop("_preview_raw", ""))
|
|
sessions.append(session)
|
|
return sessions
|
|
|
|
# ── 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 logical_size_bytes(self) -> Optional[int]:
|
|
"""Database size in bytes as SQLite itself accounts for it.
|
|
|
|
``page_count * page_size`` — the size the main DB file will have once
|
|
the WAL is checkpointed back into it.
|
|
|
|
Prefer this over ``os.path.getsize(db_path)`` when reporting the effect
|
|
of a VACUUM. In WAL mode a VACUUM's rewrite lands in the ``-wal`` file,
|
|
and the checkpoint that folds it back is refused while any other
|
|
connection (a live gateway) holds a read-mark. Until that happens the
|
|
main file on disk still carries its pre-VACUUM size and keeps growing,
|
|
so a stat()-based before/after delta understates the win and can go
|
|
negative — the "reclaimed -3820.1 MB" report on a database that had
|
|
actually shrunk 60%.
|
|
|
|
Returns None if the pragmas cannot be read.
|
|
"""
|
|
try:
|
|
with self._read_ctx() as conn:
|
|
if self._conn is None:
|
|
return None
|
|
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
|
|
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
|
return int(page_count) * int(page_size)
|
|
except Exception as exc:
|
|
logger.debug("Could not read logical DB size: %s", exc)
|
|
return None
|
|
|
|
def vacuum(self) -> int:
|
|
"""Run VACUUM to reclaim disk space after large deletes.
|
|
|
|
SQLite does not shrink the database file when rows are deleted —
|
|
freed pages just get reused on the next insert. After a prune that
|
|
removed hundreds of sessions, the file stays bloated unless we
|
|
explicitly VACUUM.
|
|
|
|
VACUUM rewrites the entire DB, so it's expensive (seconds per
|
|
100MB) and cannot run inside a transaction. It also acquires an
|
|
exclusive lock, so callers must ensure no other writers are
|
|
active. Safe to call at startup before the gateway/CLI starts
|
|
serving traffic.
|
|
|
|
FTS5 segments are merged first via :meth:`optimize_fts` so the
|
|
subsequent VACUUM reclaims the pages freed by the merge. This is a
|
|
layout-only optimization — search results are unchanged.
|
|
|
|
Returns the number of FTS indexes that were optimized (0 if the
|
|
merge step failed or no FTS tables exist).
|
|
"""
|
|
# Merge FTS5 segments before VACUUM so the freed pages are returned
|
|
# to the OS in the same pass. optimize_fts() manages its own lock.
|
|
optimized = 0
|
|
try:
|
|
optimized = self.optimize_fts()
|
|
except Exception as exc:
|
|
logger.warning("FTS optimize before VACUUM failed: %s", exc)
|
|
# VACUUM cannot be executed inside a transaction.
|
|
with self._lock:
|
|
# Best-effort WAL checkpoint first, then VACUUM. PASSIVE, not
|
|
# TRUNCATE: a manual `hermes sessions vacuum` runs in a transient
|
|
# CLI process, and a TRUNCATE reset here would race a live gateway
|
|
# writer and tear B-tree pages (#45383). VACUUM folds the WAL back
|
|
# itself; journal_size_limit bounds the file.
|
|
try:
|
|
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
|
except Exception as exc:
|
|
logger.debug("WAL checkpoint (PASSIVE) before VACUUM failed: %s", exc)
|
|
self._conn.execute("VACUUM")
|
|
# ...and again afterwards. VACUUM rewrites every page THROUGH the
|
|
# WAL, so the pre-VACUUM checkpoint above does nothing for the
|
|
# slack VACUUM itself creates: on a 3.0 GB database it left a
|
|
# 3.07 GB state.db-wal behind, so `sessions optimize` reported
|
|
# "reclaimed -11.2 MB" while actually consuming 3 GB of disk and
|
|
# filling the host to 100%. Truncating here is what makes the
|
|
# command a net win instead of a net loss on large databases.
|
|
try:
|
|
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
except Exception as exc:
|
|
logger.debug("WAL checkpoint (TRUNCATE) after VACUUM failed: %s", exc)
|
|
return optimized
|
|
|
|
def maybe_auto_prune_and_vacuum(
|
|
self,
|
|
retention_days: int = 90,
|
|
min_interval_hours: int = 24,
|
|
vacuum: bool = True,
|
|
sessions_dir: Optional[Path] = None,
|
|
min_vacuum_interval_days: int = 30,
|
|
) -> Dict[str, Any]:
|
|
"""Idempotent auto-maintenance: prune inactive sessions + optional VACUUM.
|
|
|
|
Records the last run timestamp in state_meta so subsequent calls
|
|
within ``min_interval_hours`` no-op. VACUUM has its own, typically
|
|
longer, throttle controlled by ``min_vacuum_interval_days`` so routine
|
|
pruning does not repeatedly rewrite the database. Designed to be
|
|
called once at startup from long-lived entrypoints (CLI, gateway, cron
|
|
scheduler).
|
|
|
|
When *sessions_dir* is provided, on-disk transcript files
|
|
(``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
|
|
are removed as part of the same sweep (issue #3015).
|
|
|
|
Never raises. On any failure, logs a warning and returns a dict
|
|
with ``"error"`` set.
|
|
|
|
Returns a dict with keys:
|
|
- ``"skipped"`` (bool) — true if within min_interval_hours of last run
|
|
- ``"pruned"`` (int) — number of sessions deleted
|
|
- ``"vacuumed"`` (bool) — true if VACUUM ran
|
|
- ``"error"`` (str, optional) — present only on failure
|
|
"""
|
|
result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
|
|
try:
|
|
# Skip if another process/call did maintenance recently.
|
|
last_raw = self.get_meta("last_auto_prune")
|
|
now = time.time()
|
|
if last_raw:
|
|
try:
|
|
last_ts = float(last_raw)
|
|
if now - last_ts < min_interval_hours * 3600:
|
|
result["skipped"] = True
|
|
return result
|
|
except (TypeError, ValueError):
|
|
pass # corrupt meta; treat as no prior run
|
|
|
|
pruned = self.prune_sessions(
|
|
older_than_days=retention_days,
|
|
sessions_dir=sessions_dir,
|
|
)
|
|
result["pruned"] = pruned
|
|
|
|
# Only VACUUM if we actually freed rows, and no more often than
|
|
# once every min_vacuum_interval_days -- a large prune (e.g. the
|
|
# first one to cross retention_days on a DB with tens of
|
|
# thousands of rows) can free enough pages that pruned > 0 fires
|
|
# on every subsequent startup even though a VACUUM already ran
|
|
# recently. VACUUM on this DB's size (FTS5 shadow tables) is not
|
|
# cheap -- it holds an exclusive lock for the full rewrite.
|
|
last_vacuum_raw = self.get_meta("last_vacuum")
|
|
vacuum_due = True
|
|
if last_vacuum_raw:
|
|
try:
|
|
vacuum_due = (now - float(last_vacuum_raw)) >= min_vacuum_interval_days * 86400
|
|
except (TypeError, ValueError):
|
|
vacuum_due = True
|
|
if vacuum and pruned > 0 and vacuum_due:
|
|
try:
|
|
self.vacuum()
|
|
result["vacuumed"] = True
|
|
self.set_meta("last_vacuum", str(now))
|
|
except Exception as exc:
|
|
logger.warning("state.db VACUUM failed: %s", exc)
|
|
|
|
# Record the attempt even if pruned == 0, so we don't retry
|
|
# every startup within the min_interval_hours window.
|
|
self.set_meta("last_auto_prune", str(now))
|
|
|
|
if pruned > 0:
|
|
logger.info(
|
|
"state.db auto-maintenance: pruned %d session(s) inactive for %d days%s",
|
|
pruned,
|
|
retention_days,
|
|
" + VACUUM" if result["vacuumed"] else "",
|
|
)
|
|
except Exception as exc:
|
|
# Maintenance must never block startup. Log and return error marker.
|
|
logger.warning("state.db auto-maintenance failed: %s", exc)
|
|
result["error"] = str(exc)
|
|
|
|
return result
|
|
|
|
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}.
|
|
|
|
def request_handoff(self, session_id: str, platform: str) -> bool:
|
|
"""Mark a session as pending handoff to the given platform.
|
|
|
|
Returns True if the row was found and not already in flight; False if
|
|
the session is already in a non-terminal handoff state.
|
|
"""
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"UPDATE sessions "
|
|
"SET handoff_state = 'pending', "
|
|
" handoff_platform = ?, "
|
|
" handoff_error = NULL "
|
|
"WHERE id = ? AND (handoff_state IS NULL "
|
|
" OR handoff_state IN ('completed', 'failed'))",
|
|
(platform, session_id),
|
|
)
|
|
return cur.rowcount > 0
|
|
return self._execute_write(_do)
|
|
|
|
def get_handoff_state(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Read the current handoff state for a session.
|
|
|
|
Returns ``{"state", "platform", "error"}`` or None if the session has
|
|
no handoff record.
|
|
"""
|
|
try:
|
|
with self._read_ctx() as conn:
|
|
row = conn.execute(
|
|
"SELECT handoff_state, handoff_platform, handoff_error "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"state": row["handoff_state"],
|
|
"platform": row["handoff_platform"],
|
|
"error": row["handoff_error"],
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
def list_pending_handoffs(self) -> List[Dict[str, Any]]:
|
|
"""Return all sessions in handoff_state='pending', oldest first.
|
|
|
|
Used by the gateway's handoff watcher.
|
|
"""
|
|
try:
|
|
with self._read_ctx() as conn:
|
|
rows = conn.execute(
|
|
"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.handoff_state = 'pending' "
|
|
"ORDER BY s.started_at ASC"
|
|
).fetchall()
|
|
return [self._session_row_dict(r) for r in rows]
|
|
except Exception:
|
|
return []
|
|
|
|
def claim_handoff(self, session_id: str) -> bool:
|
|
"""Atomically transition pending → running. Returns True if claimed."""
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"UPDATE sessions SET handoff_state = 'running' "
|
|
"WHERE id = ? AND handoff_state = 'pending'",
|
|
(session_id,),
|
|
)
|
|
return cur.rowcount > 0
|
|
return self._execute_write(_do)
|
|
|
|
def complete_handoff(self, session_id: str) -> None:
|
|
"""Mark a handoff as completed."""
|
|
def _do(conn):
|
|
conn.execute(
|
|
"UPDATE sessions SET handoff_state = 'completed', "
|
|
"handoff_error = NULL WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
self._execute_write(_do)
|
|
|
|
def fail_handoff(
|
|
self,
|
|
session_id: str,
|
|
error: str,
|
|
*,
|
|
only_states: Optional[Tuple[str, ...]] = None,
|
|
) -> bool:
|
|
"""Mark a handoff as failed and record the reason.
|
|
|
|
``only_states`` makes the write a compare-and-swap: the row is only
|
|
failed when its current ``handoff_state`` is in the given tuple.
|
|
Waiters that give up (CLI 60s poll, Desktop bounded poll) MUST pass
|
|
``only_states=("pending",)`` — once the gateway watcher has claimed
|
|
the row (``running``) it owns the terminal state, and a waiter-side
|
|
unconditional fail races the dispatch: the gateway later overwrites
|
|
``failed`` → ``completed`` while the user was already told the
|
|
gateway is down (split-brain — the handoff actually delivered and
|
|
``switch_session`` re-pointed the session).
|
|
|
|
The gateway watcher itself fails its OWN claimed row unconditionally
|
|
(no ``only_states``) — it is the owner while the row is ``running``.
|
|
|
|
Returns True when a row was transitioned to ``failed``.
|
|
"""
|
|
def _do(conn):
|
|
if only_states:
|
|
placeholders = ", ".join("?" for _ in only_states)
|
|
cur = conn.execute(
|
|
"UPDATE sessions SET handoff_state = 'failed', "
|
|
f"handoff_error = ? WHERE id = ? AND handoff_state IN ({placeholders})",
|
|
(error[:500], session_id, *only_states),
|
|
)
|
|
else:
|
|
cur = conn.execute(
|
|
"UPDATE sessions SET handoff_state = 'failed', "
|
|
"handoff_error = ? WHERE id = ?",
|
|
(error[:500], session_id),
|
|
)
|
|
return cur.rowcount > 0
|
|
return bool(self._execute_write(_do))
|
|
|
|
def reclaim_stale_running_handoffs(self, error: str) -> List[str]:
|
|
"""Fail every handoff stuck in ``running``. Returns the ids reclaimed.
|
|
|
|
Only the gateway's watcher ever sets ``running``, and it does so for
|
|
the duration of a single in-process dispatch. So any row still in
|
|
``running`` when a watcher starts up belongs to a PREVIOUS gateway
|
|
that died mid-dispatch (crash, kill, machine reboot).
|
|
|
|
Such a row is unrecoverable *and* poisonous: ``request_handoff`` only
|
|
accepts a new request when the state is NULL/``completed``/``failed``,
|
|
so a stranded ``running`` row makes that session permanently unable to
|
|
hand off again — with no error surfaced anywhere.
|
|
|
|
Failing (rather than re-queueing as ``pending``) is deliberate: the
|
|
dead gateway may have already switched the session key and dispatched
|
|
the synthetic turn before dying, so a blind retry risks double
|
|
delivery. The user's CLI has long since timed out; the right outcome
|
|
is a clean terminal state they can retry from explicitly.
|
|
"""
|
|
def _do(conn):
|
|
cur = conn.execute(
|
|
"SELECT id FROM sessions WHERE handoff_state = 'running'"
|
|
)
|
|
ids = [r[0] for r in cur.fetchall()]
|
|
if ids:
|
|
conn.execute(
|
|
"UPDATE sessions SET handoff_state = 'failed', "
|
|
"handoff_error = ? WHERE handoff_state = 'running'",
|
|
(error[:500],),
|
|
)
|
|
return ids
|
|
try:
|
|
return self._execute_write(_do) or []
|
|
except Exception:
|
|
# Swallow but never silently: a persistently failing reclaim
|
|
# leaves poisonous 'running' rows in place (sessions that can
|
|
# never hand off again), so the operator needs a trace of it.
|
|
logger.warning(
|
|
"reclaim_stale_running_handoffs failed; stranded 'running' "
|
|
"handoff rows (if any) were left in place", exc_info=True,
|
|
)
|
|
return []
|
|
|
|
|
|
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
|