Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
This commit is contained in:
@@ -27,7 +27,12 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
|
||||
|
||||
from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity
|
||||
from hermes_state_common import (
|
||||
TITLE_SOURCE_DERIVED as _TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM as _TITLE_SOURCE_LLM,
|
||||
TITLE_SOURCE_USER as _TITLE_SOURCE_USER,
|
||||
escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity,
|
||||
)
|
||||
from hermes_state_holders import read_only_db_uri
|
||||
from hermes_state_errors import (
|
||||
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
|
||||
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
|
||||
@@ -36,7 +41,7 @@ from hermes_state_errors import (
|
||||
)
|
||||
from hermes_state_guard import (
|
||||
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root,
|
||||
_set_last_init_error, get_last_init_error,
|
||||
_register_test_instance, _set_last_init_error, get_last_init_error,
|
||||
)
|
||||
from hermes_state_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for
|
||||
from hermes_state_sessions import SessionSessionsMixin
|
||||
@@ -102,10 +107,11 @@ class SessionResumeTooLargeError(ValueError):
|
||||
self, message_count: int, limit: int = _MAX_SAFE_MESSAGES, scope: str = "across its lineage",
|
||||
):
|
||||
self.message_count, self.limit = message_count, limit
|
||||
self.scope = scope
|
||||
super().__init__(
|
||||
f"session has at least {message_count} active messages {scope}; "
|
||||
f"safe resume limit is {limit}. Export the session instead, or set "
|
||||
"sessions.max_resume_messages: 0 in config.yaml to disable the guard."
|
||||
f"This session is too long to reload safely ({message_count} messages; limit {limit}). "
|
||||
"Start a fresh chat and use `hermes sessions export` to keep a copy, or raise the limit "
|
||||
"with `hermes config set sessions.max_resume_messages 0`."
|
||||
)
|
||||
|
||||
|
||||
@@ -296,14 +302,20 @@ def _strip_background_review_harness(messages: List[Dict[str, Any]]) -> List[Dic
|
||||
return messages
|
||||
out: List[Dict[str, Any]] = []
|
||||
skip_next_assistant = False
|
||||
previous_was_harness = False
|
||||
for msg in messages:
|
||||
if _is_background_review_harness_message(msg):
|
||||
skip_next_assistant = True
|
||||
# A consecutive harness prompt occupies the preceding prompt's
|
||||
# immediate reply slot, so it must not arm another assistant skip.
|
||||
skip_next_assistant = not previous_was_harness
|
||||
previous_was_harness = True
|
||||
continue
|
||||
if skip_next_assistant:
|
||||
skip_next_assistant = False
|
||||
if isinstance(msg, dict) and msg.get("role") == "assistant":
|
||||
previous_was_harness = False
|
||||
continue # the curator-mode reply to the harness prompt
|
||||
previous_was_harness = False
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
@@ -335,13 +347,49 @@ def _strip_stale_tool_call_markers(messages: List[Dict[str, Any]]) -> List[Dict[
|
||||
return messages
|
||||
|
||||
|
||||
def format_session_db_unavailable(prefix: str = "Session database not available") -> str:
|
||||
"""User-facing message with the captured init cause (+ WAL-docs hint for NFS/SMB locking failures)."""
|
||||
_SESSION_DB_CONSEQUENCE = "Sessions will not be saved until this is fixed."
|
||||
_NETWORK_DRIVE_HINT = " If the database lives on a network drive, move it to a local disk."
|
||||
_NETWORK_DRIVE_GLOSS = "the session database could not be opened; it may be on a network or unsupported drive"
|
||||
_NETWORK_DRIVE_ACTION = (
|
||||
"Move it to a local disk (`hermes {profile_arg}doctor` shows where it is), then start Hermes again."
|
||||
)
|
||||
|
||||
|
||||
def format_session_db_unavailable(
|
||||
prefix: str = "Hermes can't open its session history right now",
|
||||
*,
|
||||
details: bool = False,
|
||||
) -> str:
|
||||
"""User-facing one-liner: ``<prefix>: <gloss>. <consequence> <action>[ network hint]``.
|
||||
|
||||
The cause table lives in ``hermes_state_user_copy`` so CLI, gateway and TUI agree. Chat
|
||||
surfaces (gateway, TUI) get the one-liner; ``details=True`` (the CLI banner) appends a
|
||||
``Details: <raw cause>`` line for the raw SQLite text. Network filesystems (NFS/SMB/FUSE/ZFS)
|
||||
cannot host SQLite's write-ahead log: when the raw cause carries one of those markers the
|
||||
message names the network-drive suspicion, because ``hermes doctor --fix`` cannot repair a
|
||||
mount — only moving the file can."""
|
||||
from hermes_constants import profile_cli_selector
|
||||
|
||||
profile_arg = profile_cli_selector()
|
||||
cause = get_last_init_error()
|
||||
if not cause:
|
||||
return f"{prefix}."
|
||||
hint = " (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html)"
|
||||
return f"{prefix}: {cause}{hint if any(m in cause.lower() for m in _WAL_INCOMPAT_MARKERS) else ''}."
|
||||
return (
|
||||
f"{prefix}. {_SESSION_DB_CONSEQUENCE} Run `hermes {profile_arg}doctor` to check the "
|
||||
"storage location."
|
||||
)
|
||||
from hermes_state_user_copy import describe_storage_failure
|
||||
failure = describe_storage_failure(cause)
|
||||
gloss, action, hint = failure.gloss, failure.action, ""
|
||||
if any(m in cause.lower() for m in _WAL_INCOMPAT_MARKERS):
|
||||
if failure.cause == "unknown":
|
||||
gloss, action = _NETWORK_DRIVE_GLOSS, _NETWORK_DRIVE_ACTION.replace("{profile_arg}", profile_arg)
|
||||
else:
|
||||
hint = _NETWORK_DRIVE_HINT
|
||||
text = f"{prefix}: {gloss}. {_SESSION_DB_CONSEQUENCE} {action}{hint}"
|
||||
if details:
|
||||
from hermes_state_user_copy import storage_failure_details
|
||||
text += f"\nDetails: {storage_failure_details(cause)}"
|
||||
return text
|
||||
|
||||
|
||||
# Auto-repair at most once per DB path per process (no repair loops; serialises concurrent
|
||||
@@ -561,6 +609,10 @@ class SessionDB(
|
||||
if not initialization_complete:
|
||||
conn, self._conn = self._conn, None
|
||||
self._close_connection_quietly(conn)
|
||||
else:
|
||||
# Test-isolation runs only (gated inside the helper): register
|
||||
# for the suite-level leak sweep in tests/conftest.py.
|
||||
_register_test_instance(self)
|
||||
|
||||
def _open_writer(self) -> None:
|
||||
"""Writable open: preflight, zero-byte quarantine, connect + schema (one in-place repair of a
|
||||
@@ -643,7 +695,7 @@ class SessionDB(
|
||||
"""``mode=ro`` tracked connection with Row factory. check_same_thread=False: pooled connections
|
||||
are borrowed by whichever thread reads next; exclusive ownership is enforced by pool checkout."""
|
||||
conn = _connect_tracked_db(
|
||||
f"file:{self.db_path}?mode=ro", tracking_path=self.db_path, uri=True,
|
||||
read_only_db_uri(self.db_path), tracking_path=self.db_path, uri=True,
|
||||
check_same_thread=False, timeout=timeout, isolation_level=None,
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -659,13 +711,12 @@ class SessionDB(
|
||||
except OSError:
|
||||
zsize = -1
|
||||
qpath = quarantine_invalid_state_db(self.db_path, already_locked=already_locked)
|
||||
where = f"moved aside to {qpath}" if qpath else "left in place (it could not be moved aside)"
|
||||
msg = (
|
||||
f"state.db has no SQLite header ({zsize} bytes). "
|
||||
f"Preserved at {qpath or '(quarantine failed — file left in place)'}. "
|
||||
f"Restore from {self.db_path.parent / 'state-snapshots'} via `hermes snapshot list` / "
|
||||
f"`hermes snapshot restore <id>` if available, or salvage the preserved bytes with "
|
||||
f"`hermes sessions recover --source {qpath or self.db_path}`. "
|
||||
"Opening a fresh empty database so the agent can start."
|
||||
f"state.db was empty or damaged ({zsize} bytes) and has been {where}; Hermes started with a "
|
||||
"fresh, empty session database. To bring old sessions back, run "
|
||||
f"`hermes sessions recover --source {qpath or self.db_path} --inspect-only`, or restore a "
|
||||
"snapshot with `/snapshot list` then `/snapshot restore <id>` (terminal `hermes` chat only)."
|
||||
)
|
||||
logger.error(msg)
|
||||
_set_last_init_error(msg)
|
||||
@@ -1445,14 +1496,16 @@ class SessionDB(
|
||||
_TOKEN_DELTA_COST_FIELDS = ("estimated_cost_usd", "actual_cost_usd")
|
||||
_TOKEN_DELTA_ROUTE_FIELDS = (
|
||||
"model", "cost_status", "cost_source", "pricing_version", "billing_provider", "billing_base_url",
|
||||
"billing_mode",
|
||||
"billing_mode", "source",
|
||||
)
|
||||
|
||||
MAX_TITLE_LENGTH = 100
|
||||
|
||||
# Title provenance, lowest to highest authority: auto-titling may only replace a
|
||||
# strictly lower-authority title (``derived`` -> ``llm`` once; never a user-typed name).
|
||||
TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM, TITLE_SOURCE_USER = "derived", "llm", "user"
|
||||
TITLE_SOURCE_DERIVED = _TITLE_SOURCE_DERIVED
|
||||
TITLE_SOURCE_LLM = _TITLE_SOURCE_LLM
|
||||
TITLE_SOURCE_USER = _TITLE_SOURCE_USER
|
||||
_TITLE_SOURCE_RANK = {TITLE_SOURCE_DERIVED: 0, TITLE_SOURCE_LLM: 1, TITLE_SOURCE_USER: 2}
|
||||
|
||||
# Bot Mode's canonical chat is resolved by exact-title lookup: the title IS the identity,
|
||||
|
||||
Reference in New Issue
Block a user