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:
ethernet
2026-09-17 13:52:05 -04:00
2440 changed files with 120380 additions and 16107 deletions

View File

@@ -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,