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

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

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

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

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

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

1031 lines
43 KiB
Python

"""Compression lineage, cooldown/streak counters, locks and turn leases for SessionDB.
Mixin bound onto ``SessionDB`` via the MRO, built on its ``_read_ctx`` /
``_execute_write`` / ``_write_sql`` / ``_read_one`` primitives."""
from __future__ import annotations
import json
import logging
import sqlite3
import time
from typing import Any, Dict, List, Optional
from hermes_state_common import _sql_session_last_active, is_automatic_end_reason
# Log-record parity with the origin module (caplog tests pin "hermes_state").
logger = logging.getLogger("hermes_state")
class SessionCompressionMixin:
"""Compression lineage, cooldown/streak counters, locks and turn leases."""
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 whose parent was rotated elsewhere may recover only when the
lineage names exactly one live continuation; more than one fails closed
rather than guessing which transcript owns later 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.
Publication is atomic now, but older builds could leave a closed parent
after an interrupted handoff. Conservative by design: an active lease or
any canonical child means another path owns the lineage — fail closed."""
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
# Any non-branch/non-delegate/non-tool child is a continuation, ended
# or not; reopening past it could give one lineage a second live head.
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() lets an owner revive its own expired
# row, so reclaim it inside this write transaction before reopening:
# refresh-first makes the lease active and aborts recovery;
# recovery-first deletes the holder so a later refresh can't 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 in this same BEGIN
# IMMEDIATE transaction. If a False return is ever added past this
# point, raise instead: _execute_write commits the lease DELETE above unless _do raises.
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.
Closure, child row, and handoff commit in one transaction: readers see
the live parent or a complete child, never an ended parent with a
missing/empty child.
*watermark* (parent's ``get_active_message_watermark`` at compression
start): parent rows with ``id > watermark`` — appends landed during the
slow summary call — are column-cloned into the child AFTER the handoff
so they survive rotation. *watermark_ceiling* bounds the clone: the
rotation path flushes its OWN transcript to the parent just before
publishing and those rows are already in the handoff, so the caller
captures ``MAX(id)`` right BEFORE that flush and only
``(watermark, watermark_ceiling]`` is foreign tail. ``None`` = unbounded.
*require_lease_refresh* + *compression_lock_holder* refreshes the lease
on the same ``conn`` before the expiry check (no TOCTOU window), so a
refresher that died on transient DB errors gets one last chance."""
from hermes_state import CompressionSessionBusyError
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:
# An ended stamp from AUTOMATIC cleanup (tui_shutdown, ws_disconnect,
# orphan reap, idle/LRU evict) is stale by construction — this lease
# holder is still continuing the conversation. Left alone it wedges
# rotation forever (every attempt aborts here; each pre-publish flush
# re-grows the parent until the provider rejects it). Clear it; the
# closure UPDATE below re-stamps end_reason='compression'. Deliberate
# boundaries (compression, session_reset, explicit close) still fail
# closed — another path owns the 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 contract as _insert_session_row's compression-fork backfill:
# child stays on the parent's profile and keeps gateway routing/
# origin columns so peer recovery works after a boundary crash. No
# owner on either side (legacy NULL parent) → stamp this store's
# profile so the child doesn't extend the unowned lineage.
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 (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 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
try:
# Merge-max with any longer live deadline so a later shorter write
# can't reopen the thrash window; error always takes the latest diagnostic.
self._write_sql(
"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),
)
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()
row = self._read_one(
"SELECT compression_failure_cooldown_until, compression_failure_error "
"FROM sessions WHERE id = ?",
(session_id,),
)
if row is None:
return None
cooldown_until = row[0]
if cooldown_until is None:
return None
cooldown_until = float(cooldown_until)
if cooldown_until <= now:
return None
error = 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]:
"""Exact stored cooldown columns, no expiry filtering. Compression
cancellation uses this under its session lease so rollback preserves an
expired, partially-null, or absent row exactly instead of coercing it
through the active-cooldown API."""
if not session_id:
return {"session_exists": False, "cooldown_until": None, "error": None}
row = self._read_one(
"SELECT compression_failure_cooldown_until, compression_failure_error "
"FROM sessions WHERE id = ?",
(session_id,),
)
if row is None:
return {"session_exists": False, "cooldown_until": None, "error": None}
cooldown_until = row[0]
error = 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 record/clear,
this rollback API propagates write and verification failures: cancellation
must not be reported 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
try:
self._write_sql(
"UPDATE sessions SET compression_failure_cooldown_until = NULL, "
"compression_failure_error = NULL WHERE id = ?",
(session_id,),
)
except sqlite3.Error as exc:
logger.warning(
"clear_compression_failure_cooldown(%s) failed: %s",
session_id, exc,
)
def _read_session_number(self, column: str, session_id: str, cast: type, zero: Any) -> Any:
"""Read one numeric ``sessions`` column clamped at ``zero``; a missing
session, NULL, or unparsable value also reads as ``zero``."""
if not session_id:
return zero
row = self._read_one(
f"SELECT {column} FROM sessions WHERE id = ?", (session_id,)
)
if row is None:
return zero
try:
return max(zero, cast(row[0] or zero))
except (TypeError, ValueError):
return zero
def get_compression_fallback_streak(self, session_id: str) -> int:
"""Return the persisted deterministic-fallback streak."""
return self._read_session_number("compression_fallback_streak", session_id, int, 0)
def set_compression_fallback_streak(self, session_id: str, streak: int) -> None:
"""Persist the deterministic-fallback streak for one session."""
if session_id:
self._write_sql(
"UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?",
(max(0, int(streak)), session_id),
)
def get_compression_ineffective_count(self, session_id: str) -> int:
"""Persisted ineffective-compaction strike count: the durable half of
the built-in compressor's anti-thrash guard, so a fresh compressor bound
to a resumed session inherits an armed/tripped guard across restarts."""
return self._read_session_number("compression_ineffective_count", session_id, int, 0)
def set_compression_ineffective_count(self, session_id: str, count: int) -> None:
"""Persist the ineffective-compaction strike count for one session."""
if session_id:
self._write_sql(
"UPDATE sessions SET compression_ineffective_count = ? WHERE id = ?",
(max(0, int(count)), session_id),
)
def get_compression_recovery_deadline(self, session_id: str) -> float:
"""Persisted anti-thrash recovery deadline (epoch; ``0.0`` = not armed).
Durable because the gateway rebuilds the compressor every turn / cache
eviction: a process-local deadline restarted on each rebuild, so a
tripped session never earned its probe."""
return self._read_session_number("compression_recovery_deadline", session_id, float, 0.0)
def set_compression_recovery_deadline(self, session_id: str, deadline: float) -> None:
"""Persist the anti-thrash recovery deadline; ``0`` / ``None`` disarms it."""
if not session_id:
return
try:
normalized = max(0.0, float(deadline or 0.0))
except (TypeError, ValueError):
normalized = 0.0
stored = normalized if normalized > 0.0 else None
self._write_sql(
"UPDATE sessions SET compression_recovery_deadline = ? WHERE id = ?",
(stored, session_id),
)
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 ``holder`` alone, deliberately NOT ``expires_at``:
a live owner whose refresher stalled past its TTL (GC pause, loaded CI
runner, slow write escaping ``_execute_write``'s retry budget) must be
able to revive its still-unclaimed row. Requiring ``expires_at >= now``
made such a stall permanent — every later refresh matched 0 rows and the
owner kept compressing/rotating with no lease, exactly the window in
which a competing path can fork the lineage.
It cannot resurrect a lock someone else took: SQLite serialises writes,
so :meth:`try_acquire_compression_lock`'s reclaim (DELETE-expired +
INSERT-or-IGNORE) never interleaves with this UPDATE. Reclaim-first
replaces ``holder`` and this matches nothing; refresh-first pushes
``expires_at`` forward and the reclaimer's DELETE matches nothing."""
if not session_id or not holder:
return False
now = time.time()
expires_at = now + ttl_seconds
try:
return self._write_rowcount(
"UPDATE compression_locks SET expires_at = ? "
"WHERE session_id = ? AND holder = ?",
(expires_at, session_id, holder),
) > 0
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``.
``True``: caller owns the lock and must :meth:`release_compression_lock`.
``False``: another holder owns a live lock and the caller MUST NOT
compress — its rotation would race the holder's and split the lineage.
Expired locks and structured holders whose local ``pid=`` is dead are
reclaimed transparently, so a gateway killed mid-compression doesn't
stall its replacement for the full TTL. Single-transaction DELETE-expired
+ INSERT-or-IGNORE + SELECT-to-confirm; SQLite serialises writes, so it's atomic."""
from hermes_state import _compression_lock_holder_process_is_dead
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[0]
)
current_expires_at = (
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
# INSERT OR IGNORE gives no rowcount signal — 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[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,
)
# False makes the caller skip compression — 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
when the lock is gone or reclaimed; the ``holder`` check stops a late
compressor clobbering someone else's fresh lock."""
if not session_id:
return
try:
self._write_sql(
"DELETE FROM compression_locks "
"WHERE session_id = ? AND holder = ?",
(session_id, holder),
)
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 share the connection of the lease INSERT/UPDATE/DELETE: a failed
``get_session`` must not yield a child id the write then persists
(refresh would walk to the parent and fail-close). Markers bind to
``parent_session_id`` (as in ``_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
is for tests/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, not the current segment id. The walk, the INSERT, and
reclaim of expired or dead-local-PID leases share one write transaction."""
from hermes_state import _compression_lock_holder_process_is_dead
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)`` is best-effort: called when the first attempt fails
(elapsed ~0) and about every ``wait_notice_interval_seconds`` after, so
UIs can show another process holds the conversation. ``should_abort()``
True (e.g. ``/stop``) returns False at once, not after ``wait_seconds``."""
from hermes_state import classify_persistence_error
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 one 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 only — not part of the locking protocol."""
if not session_id:
return None
now = time.time()
row = self._read_one(
"SELECT holder FROM compression_locks "
"WHERE session_id = ? AND expires_at >= ?",
(session_id, now),
)
if row is None:
return None
return row[0]
def finalize_orphaned_compression_sessions(self) -> int:
"""Mark orphaned compression continuations (parent ended by compression;
child has messages, no end_reason/ended_at, api_call_count=0) as
``orphaned_compression``. Non-destructive: messages are preserved."""
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 get_compression_chain(self, session_id: str) -> List[str]:
"""Walk the compression-continuation chain forward and return every id.
Root-first, ending at the tip; ``[session_id]`` when no continuation
exists. ``get_compression_tip`` is this walk's last element — one
implementation so the two can never disagree.
A continuation is a child of a session with ``end_reason='compression'``.
Older builds also required ``child.started_at >= parent.ended_at``;
too brittle — gateway + compression races can insert the real
continuation before the parent's ``ended_at`` is written while a stale
websocket later creates a sibling that passes the timestamp test, so
desktop resume followed the sibling and recent messages looked "lost".
Instead: follow only children of compression-ended parents, exclude
explicit branch/delegate/tool children, and prefer children that continue
the chain (``end_reason='compression'``) or are still live over stale
closed siblings such as ``ws_orphan_reap``."""
current = session_id
chain = [current] if current else []
seen = {current} if current else set()
# Defensive bound; chains this deep are pathological.
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 chain
child_id = row["id"]
if not child_id or child_id in seen:
return chain
seen.add(child_id)
current = child_id
chain.append(child_id)
return chain
def get_compression_tip(self, session_id: str) -> Optional[str]:
"""Live tip of a compression chain (walk semantics: ``get_compression_chain``);
the input id when no continuation exists."""
chain = self.get_compression_chain(session_id)
return chain[-1] if chain else session_id
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":
rows = self._read_all(
"""
SELECT * FROM sessions
WHERE parent_session_id = ?
ORDER BY started_at ASC
""",
(current["id"],),
)
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:
# Later tips are included only when the requested session itself was compacted.
continue
return lineage if session_id in lineage else [session_id]