fix(state-sync): repoint to renamed agent SessionDB API + fix agent-coupled tests
The Hermes Agent's state-module refactor (agent commit 53db597201, released v2026.9.7) renamed SessionDB.set_auto_title_if_empty -> set_auto_title( session_id, title, *, source) and (agent commit 14791b4d4e) dropped the tools.approval._ApprovalEntry back-compat facade (class moved, unchanged, to tools.approval_gateway_wait). Production impact (real, silent): api.state_sync.sync_session_title called the removed set_auto_title_if_empty, whose AttributeError was swallowed by the surrounding `except Exception: logger.debug(...)`, so WebUI auto-generated session titles stopped persisting to state.db on agent >= v2026.9.7 — hermes sessions list went blank again (the exact #6892 regression). Now calls set_auto_title with LLM provenance, which preserves the identical semantics (only fills NULL/lower-authority title, returns False untouched on a manual rename, ValueError -> get_next_title_in_lineage #N de-dup). Test fixes (agent-coupled; skip on GitHub CI which installs no agent, so they only surface on an agent-equipped box like our gate): 1. tools.approval._ApprovalEntry -> idempotent autouse conftest compat shim backfilling the class from its post-split location only when the installed agent lacks the dropped facade. No production code imports it. 2. test_goal_command_webui::test_profile_goal_falls_back_when_session_db_path_is_frozen was missing the monkeypatch.syspath_prepend(...) that every sibling test in the file has, so its bare `import hermes_state` failed under full-suite ordering after a predecessor stripped the agent dir from sys.path. Added it. Verified: authoritative 5-shard CI-equivalent split all green (0 failed); each failure proven pre-existing on clean master (not this diff).
This commit is contained in:
1 parent
bd62772ca5
commit
bee7b8a9e4
4 files changed
+58
-5
No files matched your search
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
- **Docker: secrets ending in `=` (like a base64 shared secret or password) are no longer truncated when the container reloads its environment after dropping privileges, so shared-secret auth and `HERMES_WEBUI_PASSWORD` matching keep working.** `docker_init.bash`'s `load_env()` reloaded the saved env snapshot with `while IFS='=' read -r key value`, and Bash silently drops a single trailing `=` from the last field — so any value ending in one `=` (e.g. `openssl rand -base64 32` output, which is 44 chars ending in `=`) came back one character short. The reload now reads each line whole and splits at the first `=` only, preserving embedded and trailing `=`; blank/comment lines are skipped and lines without `=` keep their prior empty-value behavior. Thanks @webtecnica. (#7433, closes #7432)
|
- **Docker: secrets ending in `=` (like a base64 shared secret or password) are no longer truncated when the container reloads its environment after dropping privileges, so shared-secret auth and `HERMES_WEBUI_PASSWORD` matching keep working.** `docker_init.bash`'s `load_env()` reloaded the saved env snapshot with `while IFS='=' read -r key value`, and Bash silently drops a single trailing `=` from the last field — so any value ending in one `=` (e.g. `openssl rand -base64 32` output, which is 44 chars ending in `=`) came back one character short. The reload now reads each line whole and splits at the first `=` only, preserving embedded and trailing `=`; blank/comment lines are skipped and lines without `=` keep their prior empty-value behavior. Thanks @webtecnica. (#7433, closes #7432)
|
||||||
|
|
||||||
|
- **WebUI session titles are written to `state.db` again on the latest Hermes Agent, so `hermes sessions list` isn't blank for WebUI sessions.** The agent's `SessionDB.set_auto_title_if_empty` was renamed to `set_auto_title(session_id, title, *, source)` in a state-module refactor (released in agent v2026.9.7); the WebUI's background title sync still called the old name, whose `AttributeError` was silently swallowed — so auto-generated titles stopped persisting. The sync now calls `set_auto_title` with LLM provenance, preserving the exact prior behavior (only fills a NULL/lower-authority title, never clobbers a manual rename, de-duplicates colliding titles with a `#N` lineage suffix). Also repoints the gateway-approval test suite's `tools.approval._ApprovalEntry` reference to its post-split location (`tools.approval_gateway_wait`) via a test-only compatibility shim, so the agent-coupled tests run green across both the pre- and post-split agent.
|
||||||
|
|
||||||
- **Regenerating a response no longer refetches the entire conversation, so regenerate on a long chat is fast instead of stalling (and silently cancelling).** Clicking "regenerate" used to pull the whole transcript back from the server before it could rebuild the request, which on a large session caused a long UI freeze and could time out and silently drop the regeneration. The regeneration authority now reads only a bounded, sidecar-anchored tail of recent turns instead of the full transcript — and it is exact by construction: it takes the tail's prefix proof, keys, and rows from a single WAL-consistent database snapshot (a `data_version` check forces a full read if the database is written mid-snapshot), reuses the canonical projection and durable ordering so the bounded rows match the full reader byte-for-byte, and falls back to a full read whenever the skipped prefix isn't provably identical or the tail contains a duplicated turn key. If any of those invariants can't be met it reads the whole transcript exactly as before, so a concurrent edit or an unusual session can never produce a stale or reordered regeneration. Thanks @webtecnica. (#7204, closes #6826)
|
- **Regenerating a response no longer refetches the entire conversation, so regenerate on a long chat is fast instead of stalling (and silently cancelling).** Clicking "regenerate" used to pull the whole transcript back from the server before it could rebuild the request, which on a large session caused a long UI freeze and could time out and silently drop the regeneration. The regeneration authority now reads only a bounded, sidecar-anchored tail of recent turns instead of the full transcript — and it is exact by construction: it takes the tail's prefix proof, keys, and rows from a single WAL-consistent database snapshot (a `data_version` check forces a full read if the database is written mid-snapshot), reuses the canonical projection and durable ordering so the bounded rows match the full reader byte-for-byte, and falls back to a full read whenever the skipped prefix isn't provably identical or the tail contains a duplicated turn key. If any of those invariants can't be met it reads the whole transcript exactly as before, so a concurrent edit or an unusual session can never produce a stale or reordered regeneration. Thanks @webtecnica. (#7204, closes #6826)
|
||||||
|
|
||||||
- **Loading the session sidebar is faster on installs with many delegated-subagent sessions — the subagent classification no longer runs a separate database probe per row.** Building `/api/sessions` used to open a `state.db` connection for every session that might be a delegated child, an N+1 pattern that grew with session count. The classification now reads the authoritative `state.db` source for all candidate rows through the existing batched overlay (one read-only connection, chunked 500-ID `IN` queries), and a rich-read failure recovers the remaining IDs in a single bounded source-only pass rather than falling back to per-row queries. Behavior is unchanged — a delegated child whose index row still says `webui`/`fork` while its `state.db` source is `subagent` is classified read-only exactly as before. Thanks @starship-s. (#7231)
|
- **Loading the session sidebar is faster on installs with many delegated-subagent sessions — the subagent classification no longer runs a separate database probe per row.** Building `/api/sessions` used to open a `state.db` connection for every session that might be a delegated child, an N+1 pattern that grew with session count. The classification now reads the authoritative `state.db` source for all candidate rows through the existing batched overlay (one read-only connection, chunked 500-ID `IN` queries), and a rich-read failure recovers the remaining IDs in a single bounded source-only pass rather than falling back to per-row queries. Behavior is unchanged — a delegated child whose index row still says `webui`/`fork` while its `state.db` source is `subagent` is classified read-only exactly as before. Thanks @starship-s. (#7231)
|
||||||
|
|||||||
+14
-5
@@ -205,9 +205,11 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
|
|||||||
titles for WebUI sessions. This function bridges that gap and is called
|
titles for WebUI sessions. This function bridges that gap and is called
|
||||||
from the background title update/refresh paths after a title is persisted.
|
from the background title update/refresh paths after a title is persisted.
|
||||||
|
|
||||||
Uses ``set_auto_title_if_empty`` so it will only populate a NULL title and
|
Uses ``set_auto_title`` (LLM provenance) so it will only populate a row that
|
||||||
never overwrite a manual rename made via CLI/Gateway/TUI. This means
|
is NULL or holds a lower-authority auto-title, and never overwrites a manual
|
||||||
title refreshes (where state.db already holds the initial auto-title) are
|
rename made via CLI/Gateway/TUI (``set_auto_title`` returns ``False``,
|
||||||
|
untouched, when a higher-authority title holds the row). This means title
|
||||||
|
refreshes (where state.db already holds the initial auto-title) are
|
||||||
effectively no-ops at the state.db layer -- acceptable because the primary
|
effectively no-ops at the state.db layer -- acceptable because the primary
|
||||||
goal is ensuring ``hermes sessions list`` is not blank.
|
goal is ensuring ``hermes sessions list`` is not blank.
|
||||||
|
|
||||||
@@ -223,8 +225,15 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
|
|||||||
try:
|
try:
|
||||||
# Ensure the session row exists (idempotent) so the UPDATE has a target.
|
# Ensure the session row exists (idempotent) so the UPDATE has a target.
|
||||||
db.ensure_session(session_id=session_id, source='webui')
|
db.ensure_session(session_id=session_id, source='webui')
|
||||||
|
# hermes-agent's SessionDB.set_auto_title_if_empty was renamed to
|
||||||
|
# set_auto_title(session_id, title, *, source) in the state-module
|
||||||
|
# split (agent commit 53db597201, released v2026.9.7). set_auto_title
|
||||||
|
# preserves the same "only populate NULL / never clobber a manual
|
||||||
|
# rename" semantics (returns False, untouched, when a higher-authority
|
||||||
|
# title holds the row) and requires an explicit auto source.
|
||||||
|
_llm_source = getattr(db, "TITLE_SOURCE_LLM", "llm")
|
||||||
try:
|
try:
|
||||||
db.set_auto_title_if_empty(session_id, title)
|
db.set_auto_title(session_id, title, source=_llm_source)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# state.db enforces uniqueness on sessions.title, so a byte-identical
|
# state.db enforces uniqueness on sessions.title, so a byte-identical
|
||||||
# auto-title generated for two sessions raises ValueError here. Derive
|
# auto-title generated for two sessions raises ValueError here. Derive
|
||||||
@@ -232,7 +241,7 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
|
|||||||
# retry instead of leaving the second row blank (#6964).
|
# retry instead of leaving the second row blank (#6964).
|
||||||
alt = db.get_next_title_in_lineage(title)
|
alt = db.get_next_title_in_lineage(title)
|
||||||
if alt and alt != title:
|
if alt and alt != title:
|
||||||
db.set_auto_title_if_empty(session_id, alt)
|
db.set_auto_title(session_id, alt, source=_llm_source)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to sync session title to state.db for %s", session_id)
|
logger.debug("Failed to sync session title to state.db for %s", session_id)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1246,6 +1246,41 @@ def _restore_hermes_cli_module():
|
|||||||
sys.path[:] = _REAL_SYS_PATH
|
sys.path[:] = _REAL_SYS_PATH
|
||||||
|
|
||||||
|
|
||||||
|
# ── hermes-agent API-drift compat: tools.approval._ApprovalEntry ─────────────
|
||||||
|
# The core agent (commit 14791b4d4e, released in v2026.9.7) split tools/approval.py
|
||||||
|
# into smart/human-wait/gateway-wait modules and dropped 43 back-compat facade
|
||||||
|
# re-exports — including the module-level ``tools.approval._ApprovalEntry`` alias.
|
||||||
|
# The class itself still exists, unchanged, at ``tools.approval_gateway_wait._ApprovalEntry``
|
||||||
|
# (identical ``__init__(self, data)`` contract: threading.Event + data dict + an
|
||||||
|
# auto-stamped request_id). Several webui gateway-approval tests reference
|
||||||
|
# ``_ApprovalEntry`` through ``tools.approval`` (its pre-split public location):
|
||||||
|
# on a box with the NEW agent installed the attribute is gone (AttributeError via
|
||||||
|
# the module's PEP-562 ``__getattr__``); on an OLD agent it is still present. No
|
||||||
|
# PRODUCTION webui code imports the dropped facade (verified: api/route_approvals.py
|
||||||
|
# and api/streaming.py import only surviving names), so this is purely test-side
|
||||||
|
# coupling to a moved internal symbol.
|
||||||
|
#
|
||||||
|
# This runs as an AUTOUSE fixture (not at conftest import time) because the agent
|
||||||
|
# dir is only added to sys.path by the earlier autouse guards / server fixture —
|
||||||
|
# ``tools.approval`` is not importable at conftest module load. The backfill is
|
||||||
|
# idempotent and only aliases the class when the installed agent lacks it, using
|
||||||
|
# the exact class object the agent uses internally. It keeps the tests working
|
||||||
|
# across both the pre-split and post-split agent without touching ~22 call sites,
|
||||||
|
# and is a no-op when tools.approval isn't importable (agent absent → tests skip).
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _backfill_approval_entry_facade():
|
||||||
|
try:
|
||||||
|
import tools.approval as _approval_mod
|
||||||
|
except Exception:
|
||||||
|
return # agent not importable here → agent-dependent tests skip anyway
|
||||||
|
if getattr(_approval_mod, "_ApprovalEntry", None) is None:
|
||||||
|
try:
|
||||||
|
from tools.approval_gateway_wait import _ApprovalEntry as _entry_cls
|
||||||
|
except Exception:
|
||||||
|
return # unexpected layout → let tests skip/fail loudly, don't fake it
|
||||||
|
_approval_mod._ApprovalEntry = _entry_cls
|
||||||
|
|
||||||
|
|
||||||
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -249,6 +249,13 @@ def test_profile_goal_falls_back_when_session_db_path_is_frozen(monkeypatch, tmp
|
|||||||
"""A context API alone cannot make an import-time SessionDB path profile-safe."""
|
"""A context API alone cannot make an import-time SessionDB path profile-safe."""
|
||||||
from api import goals as webui_goals
|
from api import goals as webui_goals
|
||||||
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
||||||
|
# Ensure the agent dir is on sys.path before importing the sibling top-level
|
||||||
|
# `hermes_state` module. importorskip("hermes_cli.goals") can succeed from a
|
||||||
|
# cached sys.modules entry even after a prior shard test stripped the agent
|
||||||
|
# dir from sys.path, but the bare `import hermes_state` below is uncached and
|
||||||
|
# would then fail with ModuleNotFoundError under full-suite ordering (every
|
||||||
|
# other test in this file already does this prepend).
|
||||||
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
||||||
import hermes_state
|
import hermes_state
|
||||||
|
|
||||||
frozen_db_path = tmp_path / "frozen-home" / "state.db"
|
frozen_db_path = tmp_path / "frozen-home" / "state.db"
|
||||||
|
|||||||
Reference in new issue
Block a user