1588 lines
78 KiB
Python
1588 lines
78 KiB
Python
"""Session lifecycle mixin for SessionDB: row upsert/inheritance, lifecycle
|
|
flags (end/reopen/archive/pin/hide/read), model_config patching, listing and
|
|
counting, delete cascades, and the auto-archive sweep."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from agent.session_activity import ActivityProvenance
|
|
from hermes_state_common import (
|
|
_LISTABLE_CHILD_SQL, _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _RECOVERABLE_END_REASONS,
|
|
_RECOVERABLE_END_REASONS_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview,
|
|
_sql_session_last_active, _sql_session_last_active_by_id, escape_like as _escape_like,
|
|
)
|
|
|
|
# caplog tests pin the "hermes_state" logger name.
|
|
logger = logging.getLogger("hermes_state")
|
|
|
|
|
|
def workspace_key(row: Dict[str, Any]) -> Optional[str]:
|
|
"""Workspace grouping key: git repo root when known, else cwd, else None.
|
|
Branch is deliberately excluded so a checkout doesn't fragment history."""
|
|
return (row.get("git_repo_root") or "").strip() or (row.get("cwd") or "").strip() or None
|
|
|
|
|
|
def _delegate_from_json(col: str = "model_config") -> str:
|
|
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
|
|
|
|
|
|
# _merge_model_config_json's "no such row" result — distinct from the legal None
|
|
# ("merged config is empty → store NULL").
|
|
_MODEL_CONFIG_ROW_MISSING = object()
|
|
|
|
|
|
def _parse_model_config(raw: Any) -> Dict[str, Any]:
|
|
"""Tolerant ``model_config`` decode: JSON text or dict -> dict copy; anything else -> {}."""
|
|
if isinstance(raw, str) and raw.strip():
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
if isinstance(raw, dict):
|
|
return dict(raw)
|
|
return {}
|
|
|
|
|
|
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
|
|
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
|
|
# ``_``/``%`` are LIKE wildcards but ordinary path characters (``my_project``):
|
|
# unescaped, a prefix also matches sibling directories. The ``=`` arm is an
|
|
# exact compare and keeps the raw prefix; the Windows separator backslash
|
|
# in the LIKE pattern needs escaping too.
|
|
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]]:
|
|
"""WHERE for ``workspace_key(row) == key``: git_repo_root equals ``key``, or
|
|
(rows predating per-session git metadata) cwd is at/under ``key``. Used by
|
|
``hermes -c``/``--resume`` to pick the current workspace's MRU, not the global one."""
|
|
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],
|
|
)
|
|
|
|
|
|
# First user message of a session, shaped by _shape_preview() in Python. The
|
|
# indentation is part of the list_sessions_rich SQL text.
|
|
_PREVIEW_COL_SQL = f"""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"""
|
|
|
|
|
|
def _session_filter_where(
|
|
*, exclude_children: bool = False, source: str = None, sources: List[str] = None,
|
|
session_key: str = None, exclude_sources: List[str] = None, cwd_prefix: str = None,
|
|
min_message_count: int = 0, archived_only: bool = False, include_archived: bool = False,
|
|
) -> Tuple[List[str], List[Any]]:
|
|
"""Shared ``sessions s`` WHERE builder so session counts line up with the
|
|
listed rows. ``exclude_children`` hides sub-agent runs and compression
|
|
continuations but keeps branch/reset children: ``_LISTABLE_CHILD_SQL`` uses
|
|
the stable ``_branched_from`` marker (survives a re-ended parent) OR'd with
|
|
the legacy parent-ended-'branched' heuristic for pre-marker rows. Clause
|
|
order is part of the SQL text contract."""
|
|
where: List[str] = []
|
|
params: List[Any] = []
|
|
if exclude_children:
|
|
where.append(_LISTABLE_CHILD_SQL)
|
|
where.append(f"{_delegate_from_json('s.model_config')} IS NULL")
|
|
include_sources = [source] if source else list(sources or [])
|
|
if include_sources:
|
|
where.append(f"s.source IN ({','.join('?' for _ in include_sources)})")
|
|
params.extend(include_sources)
|
|
if session_key:
|
|
where.append("s.session_key = ?")
|
|
params.append(session_key)
|
|
if exclude_sources:
|
|
where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
|
params.extend(exclude_sources)
|
|
if cwd_prefix:
|
|
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
|
|
where.append(clause)
|
|
params.extend(clause_params)
|
|
if min_message_count > 0:
|
|
where.append("s.message_count >= ?")
|
|
params.append(min_message_count)
|
|
if archived_only:
|
|
where.append("s.archived = 1")
|
|
elif not include_archived:
|
|
where.append("s.archived = 0")
|
|
return where, params
|
|
|
|
|
|
def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
|
|
"""Delegate-subagent ids (``_delegate_from`` marker) to cascade-delete with
|
|
*parent_ids*; untagged children keep the orphan-don't-delete contract.
|
|
Walks marker chains recursively so an orchestrator's own delegates go too."""
|
|
df = _delegate_from_json()
|
|
seeds = {sid for sid in parent_ids if sid}
|
|
# Seed visited with the parents: a marker chain can loop back onto a parent
|
|
# (cycle, or a parent that is another parent's delegate child in one batch)
|
|
# and it would be collected as its own descendant and cascade-deleted.
|
|
# Callers delete parents separately; never return them as children.
|
|
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 [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 WHERE parent_session_id IN ({ph})", ids,
|
|
)
|
|
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", ids)
|
|
return ids
|
|
|
|
|
|
# Lifecycle statuses surfaced by session pickers; classified from the final
|
|
# message row ONLY (role, tool_calls, finish_reason) so it stays O(1) per session.
|
|
SESSION_STATUS_COMPLETE = "complete"
|
|
SESSION_STATUS_INTERRUPTED = "interrupted"
|
|
SESSION_STATUS_ERROR = "error"
|
|
SESSION_STATUS_EMPTY = "empty"
|
|
|
|
# finish_reason values meaning the turn ended in a provider/agent error.
|
|
_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:
|
|
"""Lifecycle from the final message: error finish → ``error``; assistant
|
|
with pending tool_calls (result never landed), or a trailing user/tool row →
|
|
``interrupted``; normal assistant finish or unknown shape → ``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":
|
|
return SESSION_STATUS_INTERRUPTED if has_tool_calls else SESSION_STATUS_COMPLETE
|
|
if r in {"user", "tool"}:
|
|
return SESSION_STATUS_INTERRUPTED
|
|
return SESSION_STATUS_COMPLETE
|
|
|
|
|
|
# Parent→child profile_name inheritance fence: keyless rows (CLI / subagent)
|
|
# inherit freely; two ``agent:<ns>:...`` keyed rows must agree on the namespace
|
|
# so a default child forked from a sibling profile's row isn't mislabelled.
|
|
_SAME_KEY_NAMESPACE_SQL = (
|
|
"p.session_key IS NULL OR sessions.session_key IS NULL"
|
|
" OR substr(p.session_key, 1, instr(substr(p.session_key, 7), ':') + 6)"
|
|
" = substr(sessions.session_key, 1, instr(substr(sessions.session_key, 7), ':') + 6)"
|
|
)
|
|
|
|
|
|
class SessionSessionsMixin:
|
|
"""Session rows: create/inherit, lifecycle flags, model_config, listing, deletion."""
|
|
|
|
_PROFILE_DIR_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
|
|
def _own_profile_name(self) -> Optional[str]:
|
|
"""The profile owning THIS store, from ``db_path`` alone (``<root>/state.db``
|
|
→ default, ``<root>/profiles/<name>/state.db`` → name). Path-based, not
|
|
get_active_profile_name(): a gateway serving a NON-launch profile opens
|
|
that profile's store and rows must carry the store's owner. None outside
|
|
the profile tree — keep 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
|
|
|
|
@staticmethod
|
|
def _inherit_parent_session_metadata(conn, session_id: str) -> None:
|
|
"""NULL-fill a child's cwd/git/profile from its parent (child creators
|
|
didn't propagate them, so lineages dropped out of the project sidebar);
|
|
profile_name only within the same ``agent:<ns>:`` namespace. The second
|
|
UPDATE inherits gateway routing columns ONLY for compression forks: a
|
|
crash before the gateway re-records the peer would otherwise strand the
|
|
child unroutable, while delegate children are spawned under a live
|
|
parent and must NOT inherit routing keys (peer recovery could repoint
|
|
gateway traffic into a subagent's session)."""
|
|
conn.execute(
|
|
f"""UPDATE sessions
|
|
SET cwd = COALESCE(sessions.cwd,
|
|
(SELECT p.cwd FROM sessions p
|
|
WHERE p.id = sessions.parent_session_id)),
|
|
git_repo_root = COALESCE(sessions.git_repo_root,
|
|
(SELECT p.git_repo_root FROM sessions p
|
|
WHERE p.id = sessions.parent_session_id)),
|
|
git_branch = COALESCE(sessions.git_branch,
|
|
(SELECT p.git_branch FROM sessions p
|
|
WHERE p.id = sessions.parent_session_id)),
|
|
profile_name = COALESCE(sessions.profile_name,
|
|
(SELECT p.profile_name FROM sessions p
|
|
WHERE p.id = sessions.parent_session_id
|
|
AND ({_SAME_KEY_NAMESPACE_SQL})))
|
|
WHERE id = ? AND parent_session_id IS NOT NULL""",
|
|
(session_id,),
|
|
)
|
|
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,),
|
|
)
|
|
|
|
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:
|
|
"""Upsert a session row, COALESCE-filling NULL columns and never
|
|
overwriting what an earlier writer set (the gateway creates a bare row
|
|
before the agent's create_session carries the real model/prompt; a later
|
|
bare source="unknown" cannot clobber it). chat_id/thread_id scope gateway
|
|
/resume (IDOR). Children backfill from the parent
|
|
(:meth:`_inherit_parent_session_metadata`); a missing profile_name is
|
|
stamped with THIS store's own profile (NULL reads as unowned)."""
|
|
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:
|
|
self._inherit_parent_session_metadata(conn, session_id)
|
|
# Transcript-critical: a failed row creation aborts the turn. Ride out long holds.
|
|
self._execute_write(_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S)
|
|
|
|
def create_session(self, session_id: str, source: str, **kwargs) -> str:
|
|
"""Create a new session record. Returns the session_id."""
|
|
self._insert_session_row(session_id, source, **kwargs)
|
|
return session_id
|
|
|
|
def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
|
|
"""Mirror ``SessionEntry.expiry_finalized`` so it survives a lost sessions.json."""
|
|
if not session_id:
|
|
return
|
|
self._write_sql(
|
|
"UPDATE sessions SET expiry_finalized = ? WHERE id = ?",
|
|
(1 if finalized else 0, session_id),
|
|
)
|
|
|
|
# ── Gateway routing index (replaces sessions.json) ────
|
|
|
|
def find_session_by_origin(
|
|
self, *, platform: str, chat_id: str, thread_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Most recent live session_id for source + chat_id (+ thread_id). With
|
|
``user_id``, exact sender matches win; if several distinct users share
|
|
the chat and none matches, 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"
|
|
rows = [dict(r) for r in self._read_all(query, params)]
|
|
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 = {u for u in (str(r.get("user_id") or "").strip() for r in rows) if u}
|
|
if len(distinct_users) > 1:
|
|
return None
|
|
return str(rows[0]["id"])
|
|
|
|
# ── Orphaned gateway-session repair (``hermes sessions repair-routing``) ──
|
|
# A write-path failure between routing publication and row creation leaves
|
|
# the live transcript in a row without identity columns, invisible to
|
|
# recovery (the chat resolves to a days-older keyed row). Widest plausible
|
|
# gap between a keyed predecessor going quiet and its unkeyed successor:
|
|
# the reported incident was ~60s; 15 minutes stays generous without
|
|
# spanning unrelated conversations.
|
|
_ORPHAN_ADOPTION_MAX_GAP_S = 900.0
|
|
|
|
# Children with a ``parent_session_id`` that are NOT compression
|
|
# continuations (branches, delegate runs, tool sessions). Markers are bound
|
|
# to the queried parent id: compression continuations inherit the rotated
|
|
# agent's model_config verbatim, so a delegate's continuation carries
|
|
# ``_delegate_from=<the delegate's own parent>`` and presence-matching
|
|
# misclassified real continuations as delegate children.
|
|
_NON_CONTINUATION_CHILD_FILTER_SQL = (
|
|
" AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'),"
|
|
" '$._branched_from'), '') != ?\n"
|
|
" AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'),"
|
|
" '$._delegate_from'), '') != ?\n AND COALESCE({alias}source, '') != 'tool'\n"
|
|
)
|
|
|
|
def end_session(self, session_id: str, end_reason: str) -> None:
|
|
"""Mark a session ended. The first end_reason wins (no-op when already
|
|
ended): a compression split must keep ``'compression'`` even if a stale
|
|
desynced-CLI end_session() targets it later. reopen_session() first to
|
|
deliberately re-end with a new reason."""
|
|
def _do(conn):
|
|
changed = conn.execute(
|
|
"UPDATE sessions SET ended_at = ?, end_reason = ? "
|
|
"WHERE id = ? AND ended_at IS NULL",
|
|
(time.time(), end_reason, session_id),
|
|
).rowcount
|
|
# Only a boundary this call wrote advances the generation (a no-op must not rotate the peer).
|
|
if changed:
|
|
self._bump_conversation_generation(conn, session_id, end_reason)
|
|
self._execute_write(_do)
|
|
|
|
def reopen_session(self, session_id: str) -> None:
|
|
"""Clear ended_at/end_reason so a session can be resumed. First stamp
|
|
markerless legacy reset children that depend on the parent's mutable
|
|
end_reason (WHERE shared with the listing predicate via
|
|
_legacy_reset_child_sql so the two cannot drift)."""
|
|
def _do(conn):
|
|
placeholders = ",".join("?" for _ in _RESET_END_REASONS)
|
|
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 an intentional reset boundary on live rows or rows with a
|
|
*recoverable* accidental end_reason; explicit boundaries are preserved
|
|
(first writer wins). Plain end_session() no-ops on an ended row, so an
|
|
``agent_close`` row would stay recoverable and stale-route recovery would
|
|
resurrect the reset session. Keep in sync with
|
|
find_latest_gateway_session_for_peer. True when promoted."""
|
|
if not session_id:
|
|
return False
|
|
now = time.time()
|
|
def _do(conn):
|
|
cursor = conn.execute(
|
|
"UPDATE sessions SET ended_at = ?, end_reason = ? "
|
|
"WHERE id = ? AND (ended_at IS NULL "
|
|
f"OR end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))",
|
|
(now, reason, session_id),
|
|
)
|
|
# /new and policy auto-resets promote rather than end_session, so the
|
|
# generation advances here too — same transaction, only when written.
|
|
if cursor.rowcount:
|
|
self._bump_conversation_generation(conn, session_id, reason)
|
|
return cursor.rowcount
|
|
try:
|
|
return bool(self._execute_write(_do))
|
|
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 fields are written only when non-empty (a probe failure never
|
|
clobbers a captured value) except under ``replace_git_meta`` (a
|
|
workspace MOVE must overwrite the old repo identity even when the new
|
|
cwd has none). Each call bumps ``git_metadata_generation``; async probes
|
|
publish via :meth:`publish_session_git_metadata` with that generation so
|
|
an older worker cannot overwrite a newer claim (A -> B -> A)."""
|
|
if not session_id or not cwd:
|
|
return None
|
|
branch = (git_branch or "").strip()
|
|
repo_root = (git_repo_root or "").strip()
|
|
def _do(conn):
|
|
current = conn.execute(
|
|
"SELECT cwd FROM sessions WHERE id = ?", (session_id,)
|
|
).fetchone()
|
|
if current is None:
|
|
return None
|
|
current_cwd = current[0]
|
|
sets = ["cwd = ?", "git_metadata_generation = COALESCE(git_metadata_generation, 0) + 1"]
|
|
params: List[Any] = [cwd]
|
|
if current_cwd != cwd or replace_git_meta:
|
|
sets.extend(("git_branch = ?", "git_repo_root = ?"))
|
|
params.extend((branch or None, repo_root or None))
|
|
elif branch:
|
|
sets.append("git_branch = ?")
|
|
params.append(branch)
|
|
if repo_root and current_cwd == cwd and not replace_git_meta:
|
|
sets.append("git_repo_root = ?")
|
|
params.append(repo_root)
|
|
params.append(session_id)
|
|
conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params)
|
|
row = conn.execute(
|
|
"SELECT git_metadata_generation FROM sessions WHERE id = ?", (session_id,),
|
|
).fetchone()
|
|
return None if row is None else int(row[0])
|
|
return self._execute_write(_do)
|
|
|
|
def publish_session_git_metadata(
|
|
self, session_id: str, cwd: str, generation: int, git_branch: Optional[str] = None,
|
|
git_repo_root: Optional[str] = None,
|
|
) -> bool:
|
|
"""Publish async Git enrichment only while its cwd claim is current."""
|
|
if (
|
|
not session_id
|
|
or not cwd
|
|
or isinstance(generation, bool)
|
|
or not isinstance(generation, int)
|
|
or generation < 1
|
|
):
|
|
return False
|
|
branch = (git_branch or "").strip()
|
|
repo_root = (git_repo_root or "").strip()
|
|
if not branch and not repo_root:
|
|
return False
|
|
sets: List[str] = []
|
|
params: List[Any] = []
|
|
if branch:
|
|
sets.append("git_branch = ?")
|
|
params.append(branch)
|
|
if repo_root:
|
|
sets.append("git_repo_root = ?")
|
|
params.append(repo_root)
|
|
params.extend((session_id, cwd, generation))
|
|
return self._write_rowcount(
|
|
f"UPDATE sessions SET {', '.join(sets)} "
|
|
"WHERE id = ? AND cwd = ? AND git_metadata_generation = ?",
|
|
params,
|
|
) == 1
|
|
|
|
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
|
|
"""Backfill git repo roots for cwds without one (pre-column sessions);
|
|
never clobbers a recorded root; empty roots are skipped."""
|
|
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
|
|
if pairs:
|
|
self._write_sql(
|
|
"UPDATE sessions SET git_repo_root = ? "
|
|
"WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
|
|
pairs, many=True,
|
|
)
|
|
|
|
# Compression locks (atomic per-session, keyed by session_id, recovered via
|
|
# expires_at) live in SessionCompressionMixin; they stop two AIAgents that
|
|
# share a session_id from both rotating it into two orphan children.
|
|
|
|
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 activity (observation-only; rate-limited by
|
|
AIAgent._touch_activity) so surfaces see API/tool/compaction activity
|
|
before any message row lands. Never moves ``last_activity_at`` backwards."""
|
|
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
|
|
self._write_sql(
|
|
"UPDATE sessions SET last_activity_at = ?, "
|
|
"last_activity_description = ?, last_activity_provenance = ? "
|
|
"WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)",
|
|
(when, desc, prov, session_id, when),
|
|
patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
|
|
)
|
|
|
|
def clear_session_activity_labels(self, session_id: str) -> None:
|
|
"""Clear activity labels after a turn (keep ``last_activity_at`` so idle /
|
|
watchdog clocks stay continuous; an idle turn must not keep advertising
|
|
"compressing"). Runs in the turn's finally: a no-op clear skips the
|
|
write transaction, a real one uses the short activity budget."""
|
|
if not session_id:
|
|
return
|
|
from agent.session_activity import ActivityProvenance
|
|
try:
|
|
row = self._read_one(
|
|
"SELECT last_activity_description, last_activity_provenance "
|
|
"FROM sessions WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
except sqlite3.Error:
|
|
row = None
|
|
if row is not None and not row[0] and (not row[1] or row[1] == ActivityProvenance.UNKNOWN.value):
|
|
return
|
|
self._write_sql(
|
|
"UPDATE sessions SET last_activity_description = ?, "
|
|
"last_activity_provenance = ? WHERE id = ?",
|
|
("", ActivityProvenance.UNKNOWN.value, session_id),
|
|
patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
|
|
)
|
|
|
|
def get_session_activity(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Return the durable activity snapshot for *session_id*, or None."""
|
|
row = self.get_session(session_id) if session_id else None
|
|
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 (COALESCE) optionally model."""
|
|
self.flush_token_counts() # barrier against queued token deltas — see update_session_model
|
|
self._write_sql(
|
|
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
|
(model_config_json, model, session_id),
|
|
)
|
|
|
|
def update_system_prompt(self, session_id: str, system_prompt: Optional[str]) -> None:
|
|
"""Store the full assembled system prompt snapshot."""
|
|
def _do(conn):
|
|
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
|
|
conn.execute(
|
|
"UPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?",
|
|
(system_prompt_hash, session_id),
|
|
)
|
|
self._delete_unreferenced_system_prompts(conn)
|
|
self._execute_write(_do)
|
|
|
|
def update_session_tool_names(self, session_id: str, tool_names: Optional[List[str]]) -> None:
|
|
"""Persist the resolved ``tools[]`` name order so a rebuilt AIAgent
|
|
(agent-cache eviction) can't fork the cached tool prefix on a flipped
|
|
check_fn verdict. ``None`` clears the pin."""
|
|
payload = json.dumps(list(tool_names)) if tool_names is not None else None
|
|
self._write_sql("UPDATE sessions SET tool_names = ? WHERE id = ?", (payload, session_id))
|
|
|
|
def update_session_model(
|
|
self, session_id: str, model: str, provider: Optional[str] = None
|
|
) -> None:
|
|
"""Set the model after a mid-session /model switch (unconditionally,
|
|
unlike update_token_counts' COALESCE), null system_prompt so stale
|
|
Model:/Provider: footers rebuild, and replace any confirmed Browser
|
|
runtime lock while keeping lineage markers. *provider* is merged into
|
|
model_config so resume recombines the model with the provider that
|
|
actually serves it, not the config.yaml primary."""
|
|
# This write bypasses the token queue: a still-queued first delta carries
|
|
# the pre-switch route and, applied after this UPDATE, would trip the
|
|
# first_accounted_route overwrite and resurrect the old model/provider.
|
|
self.flush_token_counts()
|
|
# browser_model_lock is deleted via a None patch value (same semantics
|
|
# as the old json_remove); lineage markers survive the merge.
|
|
patch: Dict[str, Any] = {"browser_model_lock": None}
|
|
if model:
|
|
patch["model"] = model
|
|
if provider:
|
|
patch["provider"] = provider
|
|
self._write_model_config_patch(
|
|
session_id, patch,
|
|
"UPDATE sessions SET model = ?, model_config = ?, "
|
|
"system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?",
|
|
lambda merged: (model, merged, session_id),
|
|
clear_prompts=True,
|
|
)
|
|
|
|
def _write_model_config_patch(
|
|
self, session_id: str, patch: Dict[str, Any], sql: str,
|
|
params: Callable[[Optional[str]], tuple], *, clear_prompts: bool = False,
|
|
) -> None:
|
|
"""Merge ``patch`` into model_config then run ``sql`` with ``params(merged)``.
|
|
|
|
One write transaction; no-op when the row doesn't exist. ``clear_prompts``
|
|
additionally garbage-collects unreferenced system_prompts (for writers
|
|
that NULL the row's system_prompt_hash).
|
|
"""
|
|
def _do(conn):
|
|
merged = self._merge_model_config_json(conn, session_id, patch)
|
|
if merged is _MODEL_CONFIG_ROW_MISSING:
|
|
return
|
|
conn.execute(sql, params(merged))
|
|
if clear_prompts:
|
|
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 model_config — the one
|
|
place the merge discipline keeping ``_branched_from``/``_delegate_from``
|
|
alive lives. ``None`` deletes a key. Runs inside the caller's write
|
|
transaction. Returns serialized JSON (``None`` when empty, matching
|
|
create_session's NULL) or ``_MODEL_CONFIG_ROW_MISSING`` when the row
|
|
doesn't exist (``on_missing="raise"`` raises ValueError instead)."""
|
|
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
|
|
config = _parse_model_config(row[0])
|
|
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 model_config atomically (``None`` removes a key);
|
|
no-op when the row or patch is empty. The transcript-coupled path is
|
|
archive_and_compact's ``model_config_patch``."""
|
|
if not session_id or not patch:
|
|
return
|
|
self._write_model_config_patch(
|
|
session_id, patch, "UPDATE sessions SET model_config = ? WHERE id = ?",
|
|
lambda merged: (merged, session_id),
|
|
)
|
|
|
|
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 {}
|
|
return _parse_model_config(session.get("model_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 into model_config (lineage
|
|
markers survive); null system_prompt so cached footers cannot lie."""
|
|
lock = {
|
|
"provider": provider or "", "model": model or "", "model_options": model_options or {},
|
|
"route_source": route_source or "", "confirmed": bool(confirmed),
|
|
"updated_at": time.time(),
|
|
}
|
|
self._write_model_config_patch(
|
|
session_id, {"browser_model_lock": lock},
|
|
"""UPDATE sessions SET
|
|
model_config = ?,
|
|
model = COALESCE(?, model),
|
|
system_prompt = NULL,
|
|
system_prompt_hash = NULL
|
|
WHERE id = ?""",
|
|
lambda merged: (merged, model, session_id),
|
|
clear_prompts=True,
|
|
)
|
|
|
|
def set_session_yolo(self, session_id: str, enabled: bool) -> None:
|
|
"""Persist the per-session YOLO flag into model_config so ``/yolo`` or
|
|
``--yolo`` survives ``hermes --resume``. No-op when the row doesn't exist
|
|
yet (creation-time model_config carries the flag for --yolo launches)."""
|
|
if not session_id:
|
|
return
|
|
self._write_model_config_patch(
|
|
session_id, {"yolo_mode": bool(enabled)},
|
|
"UPDATE sessions SET model_config = ? WHERE id = ?",
|
|
lambda merged: (merged, session_id),
|
|
)
|
|
|
|
@staticmethod
|
|
def session_yolo_enabled(session_meta: Optional[Dict[str, Any]]) -> bool:
|
|
"""Persisted YOLO flag from a session row (JSON string or parsed dict);
|
|
False on any parse failure — resume must never enable the bypass by accident."""
|
|
return bool(_parse_model_config((session_meta or {}).get("model_config")).get("yolo_mode"))
|
|
|
|
def ensure_session(
|
|
self, session_id: str, source: str = "unknown", model: str = None, **kwargs,
|
|
) -> str:
|
|
"""Ensure a session row exists (upsert). Accepts optional kwargs."""
|
|
self._insert_session_row(session_id, source, model=model, **kwargs)
|
|
return session_id
|
|
|
|
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get a session by ID (drains queued token deltas first so cost readers see exact totals)."""
|
|
self.flush_token_counts()
|
|
row = self._read_one(
|
|
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
|
|
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
|
|
"WHERE s.id = ?",
|
|
(session_id,),
|
|
)
|
|
return self._session_row_dict(row) if row else None
|
|
|
|
def get_dominant_session_model_route(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Main-loop model route that served most API calls. ``sessions`` is a
|
|
legacy aggregate mixing route changes; ``session_model_usage`` keeps the
|
|
coherent per-call tuple, so status/billing reads prefer it."""
|
|
self.flush_token_counts()
|
|
row = self._read_one(
|
|
"""SELECT model, billing_provider, billing_base_url, billing_mode,
|
|
api_call_count
|
|
FROM session_model_usage
|
|
WHERE session_id = ?
|
|
AND task = ''
|
|
AND model <> 'unknown'
|
|
AND billing_provider <> ''
|
|
ORDER BY api_call_count DESC,
|
|
(input_tokens + output_tokens + cache_read_tokens +
|
|
cache_write_tokens + reasoning_tokens) DESC,
|
|
last_seen DESC
|
|
LIMIT 1""",
|
|
(session_id,),
|
|
)
|
|
return dict(row) if row else None
|
|
|
|
def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
|
|
"""Exact id, else the single unambiguous prefix match, else None."""
|
|
exact = self.get_session(session_id_or_prefix)
|
|
if exact:
|
|
return exact["id"]
|
|
escaped = _escape_like(session_id_or_prefix)
|
|
matches = [row["id"] for row in self._read_all(
|
|
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
|
|
(f"{escaped}%",),
|
|
)]
|
|
return matches[0] if len(matches) == 1 else None
|
|
|
|
def backfill_null_session_profiles(self, profile_name: str) -> int:
|
|
"""Stamp this store's own profile onto legacy ``profile_name IS NULL``
|
|
rows, which the fail-closed owner ladder cannot route once a Desktop
|
|
registers a second connection (pre-ownership sessions became
|
|
unresumable). Single-match, not a guess: a store belongs to exactly one
|
|
profile. Never overwrites a non-NULL owner; idempotent. Returns rows stamped."""
|
|
stamp = (profile_name or "").strip()
|
|
if not stamp:
|
|
return 0
|
|
return int(self._write_rowcount(
|
|
"""UPDATE sessions
|
|
SET profile_name = ?
|
|
WHERE profile_name IS NULL OR TRIM(profile_name) = ''""",
|
|
(stamp,),
|
|
) or 0)
|
|
|
|
def _set_lineage_column(self, column: str, session_id: str, value: Any) -> bool:
|
|
"""Set one ``sessions`` column across a whole compression lineage
|
|
(ancestors + descendants joined by end_reason='compression'): Desktop
|
|
projects roots forward to their tip, and updating only the displayed tip
|
|
would let the untouched root resurrect it on refresh. True if any row changed."""
|
|
return self._write_rowcount(
|
|
f"""
|
|
WITH RECURSIVE
|
|
ancestors(id) AS (
|
|
SELECT ?
|
|
UNION
|
|
SELECT parent.id
|
|
FROM ancestors a
|
|
JOIN sessions child ON child.id = a.id
|
|
JOIN sessions parent ON parent.id = child.parent_session_id
|
|
WHERE parent.end_reason = 'compression'
|
|
),
|
|
descendants(id) AS (
|
|
SELECT ?
|
|
UNION
|
|
SELECT child.id
|
|
FROM descendants d
|
|
JOIN sessions parent ON parent.id = d.id
|
|
JOIN sessions child ON child.parent_session_id = parent.id
|
|
WHERE parent.end_reason = 'compression'
|
|
),
|
|
lineage(id) AS (
|
|
SELECT id FROM ancestors
|
|
UNION
|
|
SELECT id FROM descendants
|
|
)
|
|
UPDATE sessions
|
|
SET {column} = ?
|
|
WHERE id IN (SELECT id FROM lineage)
|
|
""",
|
|
(session_id, session_id, value),
|
|
) > 0
|
|
|
|
def set_session_archived(self, session_id: str, archived: bool) -> bool:
|
|
"""Soft-hide (or unhide) a session and its whole compression lineage;
|
|
messages are kept. True when at least one row changed."""
|
|
return self._set_lineage_column('archived', session_id, 1 if archived else 0)
|
|
|
|
# Accidental end reasons recovery treats as resumable; the same constant is
|
|
# interpolated into the recovery/promotion SQL so literals cannot drift.
|
|
RECOVERABLE_END_REASONS = _RECOVERABLE_END_REASONS
|
|
|
|
def unarchive_recoverable_session(self, session_id: str) -> bool:
|
|
"""Un-archive a session archived by a recoverable accident (ws_orphan_reap,
|
|
agent_close) — used by registry lookups like Bot Mode's canonical chat.
|
|
Deliberate archives (no end_reason, or an explicit boundary) are left
|
|
alone. True only when a recoverable row was un-archived (whole lineage)."""
|
|
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
|
|
# The accidental stamp lives on the live TIP (the registry row keeps
|
|
# end_reason='compression'); judge recoverability there.
|
|
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, or a LATER deliberate archive (which
|
|
# never writes end_reason) would auto-resurrect on the next lookup.
|
|
self._write_sql(
|
|
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (tip["id"],),
|
|
)
|
|
return True
|
|
|
|
def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
|
|
"""Pin/unpin a session and its compression lineage. Pinned sessions are
|
|
exempt from the ``sessions.auto_archive`` sweep; Desktop mirrors its
|
|
sidebar pins here so backend sweeps honour them."""
|
|
return self._set_lineage_column('pinned', session_id, 1 if pinned else 0)
|
|
|
|
def set_session_hidden(self, session_id: str, hidden: bool) -> bool:
|
|
"""Hide/unhide a session and its compression lineage from the default
|
|
list_sessions_rich listing; it stays resumable by the owning surface
|
|
(plugins such as kanban manage their own sessions)."""
|
|
return self._set_lineage_column('hidden', session_id, 1 if hidden else 0)
|
|
|
|
def set_session_read(self, session_id: str, read: bool = True) -> bool:
|
|
"""Mark read/unread across the compression lineage. ``last_read_at`` is a
|
|
watermark, not a flag: unread when activity postdates it, so new
|
|
messages flip it back without any write on the message path. NULL =
|
|
never tracked = read (shipping the column doesn't badge all history);
|
|
0 = explicitly unread; timestamp = read up to then."""
|
|
return self._set_lineage_column('last_read_at', session_id, time.time() if read else 0.0)
|
|
|
|
@staticmethod
|
|
def session_unread(session_row: Dict[str, Any]) -> bool:
|
|
"""Unread = activity postdates the ``last_read_at`` watermark (NULL = 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)
|
|
|
|
# compact_rows excludes only payload-heavy blobs no list consumer renders;
|
|
# the projection derives from SCHEMA_SQL so new columns join automatically.
|
|
_SESSION_COMPACT_EXCLUDED = frozenset(
|
|
{"system_prompt", "system_prompt_hash", "git_metadata_generation"}
|
|
)
|
|
_session_compact_cols_sql: Optional[str] = None
|
|
|
|
@staticmethod
|
|
def _chain_search_where(where_sql: str, id_needle: str, search_needle: str) -> Tuple[str, List[Any]]:
|
|
"""Extend ``where_sql`` with the id_query / search_query filters: a row is
|
|
admitted when its own id or any id in its forward compression chain
|
|
matches (search also matches titles and a punctuation-stripped form so
|
|
``an94`` finds ``AN-94``). Leading-wildcard LIKE can't use an index but
|
|
chain membership keeps it bounded — far cheaper than scanning in Python."""
|
|
params: List[Any] = []
|
|
clauses: List[str] = []
|
|
def _like_pattern(needle: str) -> str:
|
|
return f"%{_escape_like(needle)}%"
|
|
if id_needle:
|
|
clauses.append(
|
|
"EXISTS (SELECT 1 FROM chain cq WHERE cq.root_id = s.id"
|
|
" AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
|
|
)
|
|
params.append(_like_pattern(id_needle))
|
|
if search_needle:
|
|
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 '\\'"
|
|
)
|
|
params.extend([_like_pattern(search_needle)] * 2)
|
|
if compact_needle:
|
|
search_clause += f" OR {compact_sql.format('cs.title')} LIKE ? ESCAPE '\\'"
|
|
params.append(_like_pattern(compact_needle))
|
|
clauses.append(search_clause + "))")
|
|
if not clauses:
|
|
return where_sql, params
|
|
combined = " AND ".join(clauses)
|
|
return (f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}"), params
|
|
|
|
def _project_compression_tips(self, sessions: List[Dict[str, Any]], compact_rows: bool) -> List[Dict[str, Any]]:
|
|
"""Replace each compression root's surfaced fields with its live tip's
|
|
(root ``started_at`` kept for stable ordering); tip rows are fetched in
|
|
one batched query. ``_lineage_ids`` carries every id on the chain: a
|
|
persisted tile can hold a MIDDLE segment's id, and with only root/tip a
|
|
surface cannot prove it names this conversation (one chat open twice)."""
|
|
tip_ids_by_root: Dict[str, str] = {}
|
|
chain_by_root: Dict[str, List[str]] = {}
|
|
for s in sessions:
|
|
if s.get("end_reason") != "compression":
|
|
continue
|
|
chain = self.get_compression_chain(s["id"])
|
|
tip_id = chain[-1] if chain else s["id"]
|
|
if tip_id != s["id"]:
|
|
tip_ids_by_root[s["id"]] = tip_id
|
|
chain_by_root[s["id"]] = chain
|
|
tip_rows = (
|
|
self._get_session_rich_rows_batch(set(tip_ids_by_root.values()), compact_rows=compact_rows)
|
|
if tip_ids_by_root else {}
|
|
)
|
|
projected = []
|
|
for s in sessions:
|
|
tip_id = tip_ids_by_root.get(s["id"])
|
|
tip_row = tip_rows.get(tip_id) if tip_id else None
|
|
if not tip_row:
|
|
projected.append(s)
|
|
continue
|
|
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"]
|
|
merged["_lineage_ids"] = chain_by_root.get(s["id"]) or None
|
|
projected.append(merged)
|
|
return projected
|
|
|
|
@classmethod
|
|
def _list_row(cls, row: sqlite3.Row) -> Dict[str, Any]:
|
|
"""Project a list_sessions_rich row: shape the preview, drop internal ordering columns."""
|
|
s = cls._session_row_dict(row)
|
|
s["preview"] = _shape_preview(s.pop("_preview_raw", ""))
|
|
s.pop("_effective_last_active", None)
|
|
return s
|
|
|
|
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 and ``last_active`` in one query. Subagent
|
|
runs / compression continuations are hidden unless ``include_children``;
|
|
``project_compression_tips`` shows each chain as its live tip;
|
|
``order_by_last_active`` sorts by the chain TIP via a recursive CTE (the
|
|
only path honouring ``id_query`` / ``search_query``); ``compact_rows``
|
|
omits the system_prompt blob; ``include_pinned`` back-fills pins the page
|
|
missed ("always reachable"), still obeying the other filters."""
|
|
self.flush_token_counts() # rows carry token/cost totals
|
|
where_clauses, params = _session_filter_where(
|
|
exclude_children=not include_children, source=source, sources=sources,
|
|
session_key=session_key, exclude_sources=exclude_sources, cwd_prefix=cwd_prefix,
|
|
min_message_count=min_message_count, archived_only=archived_only,
|
|
include_archived=include_archived,
|
|
)
|
|
if not include_hidden:
|
|
where_clauses.append("s.hidden = 0")
|
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
base_where_params = list(params) # pinned back-fill reuses the WHERE before LIMIT/OFFSET
|
|
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"
|
|
)
|
|
_sel = self._compact_session_cols() if compact_rows else "s.*"
|
|
id_needle = (id_query or "").strip().lower()
|
|
search_needle = (search_query or "").strip().lower()
|
|
if order_by_last_active:
|
|
# The CTE seeds from rows the outer WHERE admits and walks
|
|
# compression-continuation edges forward; MAX over the chain gives
|
|
# effective_last_active so ORDER BY + LIMIT happen in SQL. Do NOT
|
|
# require child.started_at >= parent.ended_at: races insert the
|
|
# continuation before the parent's ended_at is written, while stale
|
|
# websocket siblings could pass the timestamp test and hijack projection.
|
|
outer_where, id_params = self._chain_search_where(where_sql, id_needle, search_needle)
|
|
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},
|
|
{_PREVIEW_COL_SQL},
|
|
{_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 ?
|
|
"""
|
|
params = params + params + id_params + [limit, offset] # WHERE binds twice (seed + outer)
|
|
else:
|
|
query = f"""
|
|
SELECT {_sel}{prompt_select},
|
|
{_PREVIEW_COL_SQL},
|
|
{_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])
|
|
sessions = [self._list_row(row) for row in self._read_all(query, params)]
|
|
# Pinned back-fill runs BEFORE compression projection so a back-filled
|
|
# root projects to its tip like any other row. One query, never N+1.
|
|
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")
|
|
pinned_query = f"""
|
|
SELECT {_sel}{prompt_select},
|
|
{_PREVIEW_COL_SQL},
|
|
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
|
|
"""
|
|
for row in self._read_all(pinned_query, base_where_params):
|
|
s = self._list_row(row)
|
|
if s["id"] not in seen_ids:
|
|
seen_ids.add(s["id"])
|
|
sessions.append(s)
|
|
if project_compression_tips and not include_children:
|
|
sessions = self._project_compression_tips(sessions, compact_rows)
|
|
# last_read_at is lineage-stamped, so root and tip watermarks agree.
|
|
for s in sessions:
|
|
s["unread"] = self.session_unread(s)
|
|
return sessions
|
|
|
|
def session_lifecycle_statuses(self, session_ids: List[str]) -> Dict[str, str]:
|
|
"""``{session_id: status}`` from each session's LAST message row (see
|
|
:func:`classify_session_status`; ``'empty'`` when no messages). One query:
|
|
MAX(id) per session (index seek) joined back for that row — never scans transcripts."""
|
|
ids = [sid for sid in (session_ids or []) if sid]
|
|
if not ids:
|
|
return {}
|
|
statuses: Dict[str, str] = {sid: "empty" for sid in ids}
|
|
placeholders = ",".join("?" for _ in ids)
|
|
query = f"""
|
|
SELECT m.session_id, m.role,
|
|
m.tool_calls IS NOT NULL AS has_tool_calls,
|
|
m.finish_reason
|
|
FROM messages m
|
|
JOIN (
|
|
SELECT session_id, MAX(id) AS max_id
|
|
FROM messages
|
|
WHERE session_id IN ({placeholders})
|
|
GROUP BY session_id
|
|
) latest ON m.id = latest.max_id
|
|
"""
|
|
rows = self._read_all(query, ids)
|
|
for row in rows:
|
|
statuses[row["session_id"]] = classify_session_status(
|
|
role=row["role"], has_tool_calls=bool(row["has_tool_calls"]),
|
|
finish_reason=row["finish_reason"],
|
|
)
|
|
return statuses
|
|
|
|
def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None) -> int:
|
|
"""Active row count of this segment (compression ancestors excluded), or
|
|
raise SessionExportTooLargeError. The LIMITed subquery stops once it
|
|
proves the bound is exceeded. ``None`` resolves ``sessions.max_export_messages``;
|
|
0 disables the guard (returns 0 without counting)."""
|
|
from hermes_state import SessionExportTooLargeError, resolved_max_export_messages
|
|
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:
|
|
return 0
|
|
row = self._read_one(
|
|
"SELECT COUNT(*) FROM ("
|
|
"SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)",
|
|
(session_id, max_messages + 1),
|
|
)
|
|
message_count = int(row[0] if row else 0)
|
|
if message_count > max_messages:
|
|
raise SessionExportTooLargeError(session_id, message_count, max_messages)
|
|
return message_count
|
|
|
|
def _is_explicit_branch_session(self, session_id: str) -> bool:
|
|
"""Copied user-facing branch (``_branched_from`` marker)? Branches own a
|
|
copied transcript; compression continuations need the parent's archived rows."""
|
|
if not session_id:
|
|
return False
|
|
row = self._read_one("SELECT model_config FROM sessions WHERE id = ?", (session_id,))
|
|
if row is None:
|
|
return False
|
|
return bool(_parse_model_config(row[0]).get("_branched_from"))
|
|
|
|
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
|
|
if not session_id:
|
|
return [session_id]
|
|
chain = []
|
|
current = session_id
|
|
seen = set()
|
|
with self._read_ctx() as conn:
|
|
for _ in range(100):
|
|
if not current or current in seen:
|
|
break
|
|
seen.add(current)
|
|
chain.append(current)
|
|
row = conn.execute(
|
|
"SELECT parent_session_id FROM sessions WHERE id = ?", (current,),
|
|
).fetchone()
|
|
if row is None:
|
|
break
|
|
current = row[0]
|
|
return list(reversed(chain)) or [session_id]
|
|
|
|
def search_sessions(
|
|
self, source: str = None, limit: int = 20, offset: int = 0, workspace_key: str = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Sessions MRU-first with a computed ``last_active``; ``workspace_key``
|
|
scopes to one workspace (:func:`workspace_key` semantics) so
|
|
``hermes -c``/``--resume`` picks the current workspace's last session."""
|
|
select_with_last_active = (
|
|
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, "
|
|
f"{_sql_session_last_active('s')} AS last_active "
|
|
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
|
|
)
|
|
where_clauses = []
|
|
params: list = []
|
|
if source:
|
|
where_clauses.append("s.source = ?")
|
|
params.append(source)
|
|
if workspace_key:
|
|
ws_clause, ws_params = _workspace_key_clause(workspace_key)
|
|
where_clauses.append(ws_clause)
|
|
params.extend(ws_params)
|
|
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
params.extend([limit, offset])
|
|
return [self._session_row_dict(row) for row in self._read_all(
|
|
f"{select_with_last_active}{where_sql} "
|
|
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
|
|
params,
|
|
)]
|
|
|
|
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 with the same filters as list_sessions_rich, so a
|
|
paired "load more" total matches the listable rows (children or a
|
|
cron-excluded page would otherwise inflate it and never settle)."""
|
|
where_clauses, params = _session_filter_where(
|
|
exclude_children=exclude_children, source=source, sources=sources,
|
|
exclude_sources=exclude_sources, cwd_prefix=cwd_prefix,
|
|
min_message_count=min_message_count,
|
|
archived_only=archived_only, include_archived=include_archived,
|
|
)
|
|
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
return self._read_one(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)[0]
|
|
|
|
def session_count_ge(self, n: int = 1) -> bool:
|
|
"""At least N sessions exist (archived included — "has this install ever
|
|
had sessions"). LIMIT short-circuits: 4us vs session_count()'s 543us
|
|
index scan on a 20k-session DB."""
|
|
rows = self._read_all("SELECT 1 FROM sessions LIMIT ?", (n,))
|
|
return len(rows) >= n
|
|
|
|
def session_count_by_source(
|
|
self, *, include_archived: bool = False, archived_only: bool = False,
|
|
exclude_children: bool = False,
|
|
) -> Dict[str, int]:
|
|
"""``{source: count}`` via one GROUP BY (uses idx_sessions_source unless
|
|
``exclude_children``, whose predicates need a table scan like
|
|
list_sessions_rich). ``exclude_children`` mirrors listing visibility."""
|
|
where_clauses, params = _session_filter_where(
|
|
exclude_children=exclude_children,
|
|
archived_only=archived_only, include_archived=include_archived,
|
|
)
|
|
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 declared_scope_identity(self, session_id: str) -> Tuple[bool, str]:
|
|
"""(is_fork_child, source) for *session_id* in ONE read — prompt_cache_scope
|
|
needs both from the same row. A missing row is (False, ""); DB errors
|
|
propagate so the caller fails closed."""
|
|
session = self.get_session(session_id)
|
|
if not session:
|
|
return False, ""
|
|
return (self._is_explicit_fork_child_row(session), str(session.get("source") or "").strip())
|
|
|
|
@staticmethod
|
|
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
|
|
"""Remove ``<id>.json``/``.jsonl`` and gateway ``request_dump_<id>_*.json``;
|
|
OSError is swallowed so a filesystem hiccup never blocks a DB operation."""
|
|
if sessions_dir is None:
|
|
return
|
|
targets = [sessions_dir / f"{session_id}{suffix}" for suffix in (".json", ".jsonl")]
|
|
try:
|
|
# request_dump files use session_id as a prefix component
|
|
targets.extend(sessions_dir.glob(f"request_dump_{session_id}_*.json"))
|
|
except OSError:
|
|
pass
|
|
for p in targets:
|
|
try:
|
|
p.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
def get_session_delete_targets(self, session_id: str) -> List[str]:
|
|
"""Rows :meth:`delete_session` would remove: the session, then its
|
|
recursive delegate children (branch/compression children are orphaned, not deleted)."""
|
|
with self._read_ctx() as conn:
|
|
if not conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone():
|
|
return []
|
|
# The borrowed read connection, never self._conn (unlocked writer use).
|
|
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 its messages. Delegate children cascade (they'd
|
|
resurface as orphans in pickers); branch/compression children are
|
|
orphaned (parent -> NULL). *sessions_dir*: also remove transcript files.
|
|
*expected_delete_ids*: proceed only if parent + delegate cascade still
|
|
equals that set (export-before-delete fails closed if a new delegate
|
|
appeared); the tree is re-walked inside the transaction on purpose (TOCTOU)."""
|
|
removed_delegate_ids: List[str] = []
|
|
expected_ids = set(expected_delete_ids) if expected_delete_ids is not None else None
|
|
def _do(conn):
|
|
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
|
|
return False
|
|
if expected_ids is not None and expected_ids != {
|
|
session_id, *_collect_delegate_child_ids(conn, [session_id])
|
|
}:
|
|
return False
|
|
removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id]))
|
|
conn.execute( # orphan remaining children (branches) so FK is satisfied
|
|
"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 sid in removed_delegate_ids + [session_id]:
|
|
self._remove_session_files(sessions_dir, sid)
|
|
return bool(deleted)
|
|
|
|
def delete_session_if_empty(self, session_id: str, sessions_dir: Optional[Path] = None) -> bool:
|
|
"""Delete *session_id* only if it has no messages, no title and no
|
|
children (a parent that spawned work is not "empty"), so start-and-quit
|
|
sessions don't pile up in /resume. Check and delete share one
|
|
transaction so a concurrently flushed message can't be lost."""
|
|
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:
|
|
"""Bulk delete (dashboard multi-select) with :meth:`delete_session`
|
|
semantics per row, in ONE transaction so a partial failure can't leave
|
|
"messages gone, row still there". Unknown ids are skipped (UI selection
|
|
can race another tab's delete: succeed-on-the-rest). Returns the number
|
|
that actually existed and were deleted."""
|
|
if not session_ids:
|
|
return 0
|
|
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):
|
|
# Filter to IDs that actually exist: return the real deleted count.
|
|
existing = [row["id"] for row in conn.execute(
|
|
f"SELECT id FROM sessions WHERE id IN ({','.join('?' * len(unique_ids))})",
|
|
unique_ids,
|
|
).fetchall()]
|
|
if not existing:
|
|
return 0
|
|
existing_placeholders = ",".join("?" * len(existing))
|
|
removed_delegate_ids.extend(_delete_delegate_children(conn, existing))
|
|
conn.execute( # orphan children whose parent is in the kill list (FK)
|
|
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 + removed_ids:
|
|
self._remove_session_files(sessions_dir, sid)
|
|
return count
|
|
|
|
#: Shared by count_empty_sessions / delete_empty_sessions so badge and sweep
|
|
#: agree. ``message_count`` counts live rows only — rewind and compaction
|
|
#: reset it to 0 while keeping dropped turns as ``active = 0`` (the only
|
|
#: recoverable copy) — so NOT EXISTS is the authority; message_count = 0 is
|
|
#: a cheap prefilter.
|
|
_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:
|
|
"""Count of empty, ended, non-archived sessions (:data:`_EMPTY_SESSION_WHERE`).
|
|
The ended_at guard matches prune_sessions: a fresh session whose first
|
|
message hasn't landed is never sniped out from under the runtime."""
|
|
return self._read_one(f"SELECT COUNT(*) FROM sessions WHERE {self._EMPTY_SESSION_WHERE}")[0]
|
|
|
|
def delete_empty_sessions(self, sessions_dir: Optional[Path] = None) -> int:
|
|
"""Delete every empty, ended, non-archived session (:data:`_EMPTY_SESSION_WHERE`)
|
|
in one transaction, orphaning (not cascading) children so branch/subagent
|
|
transcripts survive. Transcript files are swept too: the gateway can
|
|
leave a stub request_dump_* if it crashed before the first reply."""
|
|
removed_ids: list[str] = []
|
|
def _do(conn):
|
|
session_ids = {row["id"] for row in conn.execute(
|
|
f"SELECT id FROM sessions WHERE {self._EMPTY_SESSION_WHERE}"
|
|
).fetchall()}
|
|
if not session_ids:
|
|
return 0
|
|
conn.execute(
|
|
f"UPDATE sessions SET parent_session_id = NULL "
|
|
f"WHERE parent_session_id IN ({','.join('?' * len(session_ids))})",
|
|
list(session_ids),
|
|
)
|
|
for sid in session_ids:
|
|
# DELETE FROM messages is paranoia — the selector's NOT EXISTS
|
|
# probe proved these own no rows — but a row inserted between
|
|
# the SELECT and here would otherwise dangle (clean FK state).
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
|
removed_ids.append(sid)
|
|
self._delete_unreferenced_system_prompts(conn)
|
|
return len(session_ids)
|
|
count = self._execute_write(_do)
|
|
for sid in removed_ids:
|
|
self._remove_session_files(sessions_dir, sid)
|
|
return count
|
|
|
|
def archive_sessions(
|
|
self, older_than_days: Optional[float] = None, source: str = None, **filters,
|
|
) -> int:
|
|
"""Bulk soft-hide with prune_sessions' filter surface, via
|
|
set_session_archived so each lineage flips as a unit. ``archived``
|
|
defaults to False so repeat runs are idempotent. Returns matches."""
|
|
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 maybe_auto_archive(
|
|
self, idle_days: float = 3, min_interval_hours: int = 24, exclude_pinned: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""Idempotent auto-archive of sessions idle for ``idle_days`` (ages on last
|
|
activity, non-destructive). ``state_meta['last_auto_archive']`` gates
|
|
runs within ``min_interval_hours``; safe to call opportunistically.
|
|
Never raises: {"skipped", "archived", "error"?}."""
|
|
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 = result["archived"] = self.archive_stale_sessions(idle_days, exclude_pinned=exclude_pinned)
|
|
# Record even a zero-archive run so we don't re-sweep every call.
|
|
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
|