fix(state): per-profile store housekeeping under one multiplexed process

Three silent data-correctness bugs on a host where ONE process serves every
profile:

- The gateway constructor ran auto-archive and auto-prune/VACUUM once, on a
  handle pinned to the construction-time launch home, so a served secondary
  profile's state.db was never pruned or vacuumed by anybody. Both now run per
  SERVED profile, inside that profile's runtime scope, against its own
  SessionDB, its own `sessions:` config and its own transcript dir.
- The dashboard/`hermes serve` auto-archive sweep archived an arbitrary
  profile's store but read the config through the PROCESS HERMES_HOME, so one
  profile's sessions.auto_archive_days governed every other profile's
  retention. It now loads the config of the home whose store it sweeps.
- Auto-VACUUM admission read "no FOREIGN holder" as "the store is quiet", but
  the /proc scan skips our own pid by design. VACUUM plus its TRUNCATE
  checkpoint therefore retired a WAL generation another live SessionDB in THIS
  process still held. The path-keyed registry now answers the in-process half.

Also: argv naming only the install root no longer dismisses a multiplexer as
"another instance" when the store is a profile store beneath that root — under
one-process-per-host that argv is exactly what the holder looks like.
This commit is contained in:
teknium1
2026-09-21 01:14:16 -07:00
committed by Teknium
parent 9ecd22e5da
commit a2c0af430c
9 changed files with 321 additions and 38 deletions

View File

@@ -3704,25 +3704,16 @@ class GatewayRunner(
# state.db corruption or NFS/SMB lock failures silently degrade the entire gateway — messages may
# flow but nothing is persisted, and the user has no indication until they try /resume and find
# nothing (#88235).
if self._session_db is not None:
try:
from hermes_cli.config import load_config as _load_full_config
_sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_archive", False):
self._session_db._db.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)))
if _sess_cfg.get("auto_prune", False):
# Construction-time, before the loop serves traffic; sync DB is fine.
self._session_db._db.maybe_auto_prune_and_vacuum(
retention_days=int(_sess_cfg.get("retention_days", 90)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
min_vacuum_interval_days=int(
_sess_cfg.get("min_vacuum_interval_days", 30)),
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
sessions_dir=self.config.sessions_dir)
except Exception as exc:
logger.debug("state.db auto-maintenance skipped: %s", exc)
# Once per SERVED profile, each under its own scope: both the store and the ``sessions:``
# config that governs it must be the profile's own. Bound to ``self._session_db`` this ran
# against the construction-time launch home only, so a multiplexed secondary profile's
# state.db was never pruned or vacuumed by anybody, and the launch profile's
# retention_days/auto_prune decided whether it happened at all.
from gateway.run_profile_reconcile import _for_each_served_profile
_housekeeping_chore(
"state.db startup maintenance",
lambda: _for_each_served_profile(
self, lambda _label: _housekeeping_state_db_maintenance()))
# Checkpoint store pruning is a housekeeping chore (``_housekeeping_checkpoint_prune``), not a
# constructor step: its ``git gc`` repacks the whole store (tens of seconds on a GB store) and
# here it ran before the control socket, adapters and the code_sha stamp — so the first
@@ -4620,25 +4611,37 @@ def _housekeeping_org_skill_sync() -> None:
maybe_pull_org_skills()
def _housekeeping_auto_archive() -> None:
"""Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive()
is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound.
def _housekeeping_state_db_maintenance() -> None:
"""Stale-session auto-archive plus auto-prune/VACUUM for ONE profile's state.db; both are gated
by sessions.min_interval_hours (VACUUM additionally by its own throttles). Opens its own
SessionDB — SQLite connections are thread-bound.
Profile-scoped by its caller: ``acquire()`` and ``load_config()`` both resolve through
``get_hermes_home()``, so an unscoped tick swept only the LAUNCH profile's store and a
multiplexed secondary was never archived by anyone — the dashboard/serve trigger defers to
the gateway for every profile a gateway owns (``web_server_sessions``)."""
Profile-scoped by its caller: ``acquire()``, ``get_hermes_home()`` and ``load_config()`` all
resolve through the active scope, so an unscoped run swept only the LAUNCH profile's store with
the LAUNCH profile's retention settings and a multiplexed secondary was never archived, pruned
or vacuumed by anyone — the dashboard/serve trigger defers to the gateway for every profile a
gateway owns (``web_server_sessions``)."""
from hermes_cli.config import load_config as _load_full_config
from hermes_state_registry import acquire, release_or_close
_sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_archive", False):
_adb = acquire()
try:
if not (_sess_cfg.get("auto_archive", False) or _sess_cfg.get("auto_prune", False)):
return
_adb = acquire()
try:
if _sess_cfg.get("auto_archive", False):
_adb.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)))
finally:
release_or_close(_adb)
if _sess_cfg.get("auto_prune", False):
_adb.maybe_auto_prune_and_vacuum(
retention_days=int(_sess_cfg.get("retention_days", 90)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
min_vacuum_interval_days=int(_sess_cfg.get("min_vacuum_interval_days", 30)),
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
# This profile's own transcript dir, not the launch profile's ``config.sessions_dir``.
sessions_dir=get_hermes_home() / "sessions")
finally:
release_or_close(_adb)
def _housekeeping_deferred_fts_retry() -> None:
@@ -4731,7 +4734,7 @@ def _start_gateway_housekeeping(
(60, "Curator tick", profile_scoped_chore(runner, _housekeeping_curator)),
(60, "Sync pull tick", profile_scoped_chore(runner, _housekeeping_skill_sync)),
(60, "Org sync pull tick", profile_scoped_chore(runner, _housekeeping_org_skill_sync)),
(60, "Auto-archive tick", profile_scoped_chore(runner, _housekeeping_auto_archive)),
(60, "state.db maintenance tick", profile_scoped_chore(runner, _housekeeping_state_db_maintenance)),
(1, "Deferred FTS retry tick", _housekeeping_deferred_fts_retry),
(1, "gateway housekeeping memory trim", _housekeeping_memory_trim),
(1, "MCP config reconcile", _mcp_config_reconciler(runner)),

View File

@@ -228,20 +228,31 @@ def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
_last_auto_archive_check[key] = now
from hermes_cli.config import load_config as _load_full_config
cfg = (_load_full_config().get("sessions") or {})
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
# The config that governs a store is the one in that store's OWN home. A zero-arg
# load_config() resolves through the PROCESS HERMES_HOME, so the dashboard swept every
# profile's sessions with the launch profile's sessions.auto_archive/auto_archive_days —
# one profile's retention silently decided another's.
profile_home = _session_db_path_for_profile(profile).parent
_home_token = set_hermes_home_override(str(profile_home))
try:
cfg = (_load_full_config().get("sessions") or {})
finally:
reset_hermes_home_override(_home_token)
if not cfg.get("auto_archive", False):
return
from hermes_cli.profiles import _check_gateway_running
# A live gateway owns this profile's store and runs the same sweep on its own
# housekeeping tick ("Auto-archive tick" in gateway/run.py, profile-scoped so a
# housekeeping tick ("state.db maintenance tick" in gateway/run.py, profile-scoped so a
# multiplexed secondary's store is swept too). Opening it WRITABLE from `hermes
# serve` adds a second writer to a database another process is already archiving,
# for zero extra coverage (#110405). `_check_gateway_running` is the canonical
# per-profile predicate (`_maybe_run_skill_maintenance` below uses it): its
# multiplexer rung catches a served secondary, which owns no gateway.pid or lock
# of its own and a bare lock-file probe would report stopped.
if _check_gateway_running(_session_db_path_for_profile(profile).parent):
if _check_gateway_running(profile_home):
return
db = _open_session_db_for_profile(profile, read_only=False)
try:

View File

@@ -156,9 +156,23 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
this instance's stale-FTS rebuild forever despite lsof proving zero open
handles). Ambiguous argv without absolute-path tokens returns False and
keeps the fail-closed suspicion.
One process per host serves EVERY profile, so a token naming the install
root is not evidence of another instance when ``db_path`` is a profile
store under it: a default-launched multiplexer holds
``<root>/profiles/<name>/state.db`` while its argv mentions only
``<root>``. Only a token under a DIFFERENT install root still counts.
"""
db_path_str = os.path.abspath(os.fspath(db_path))
this_home = os.path.dirname(db_path_str)
# ``<root>/profiles/<name>/state.db`` -> ``<root>``; the multiplexer scoped to the
# install root is a candidate holder of every profile store beneath it.
profiles_dir = os.path.dirname(this_home)
install_root = (
os.path.dirname(profiles_dir)
if os.path.basename(profiles_dir) == "profiles"
else None
)
ours = {
os.path.normcase(candidate)
for candidate in (
@@ -166,8 +180,13 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
db_path_str + "-wal",
db_path_str + "-shm",
this_home,
*((install_root,) if install_root else ()),
)
}
own_prefixes = tuple(
os.path.normcase(root) + os.sep
for root in (this_home, *((install_root,) if install_root else ()))
)
other_home_seen = False
for token in argv:
if not isinstance(token, str):
@@ -183,7 +202,7 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
path_token = None
if path_token is not None:
normalized = os.path.normcase(os.path.normpath(path_token))
if normalized in ours or normalized.startswith(this_home + os.sep):
if normalized in ours or normalized.startswith(own_prefixes):
return False
if "/.hermes" in normalized or normalized.endswith("/.hermes"):
other_home_seen = True
@@ -335,6 +354,24 @@ def foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
return holders
def in_process_state_db_holders(
db_path: Path, *, exclude=None
) -> List[Tuple[int, str]]:
"""Return holders of ``db_path`` inside THIS process, other than *exclude*.
:func:`foreign_state_db_holders` skips ``os.getpid()`` by design, so it answers a
cross-PROCESS question only. Consumers that read "no holders" as "the store is quiet"
(auto-VACUUM admission) need this arm too: a VACUUM plus its TRUNCATE checkpoint retires
the generation a sibling SessionDB in this very process still holds.
"""
from hermes_state_registry import other_generations_for_path
return [
(os.getpid(), description)
for description in other_generations_for_path(db_path, exclude=exclude)
]
def held_store_refusal(db_path: Path, *, command: str, force_hint: Optional[str] = "--force") -> Optional[str]:
"""Operator-facing refusal for structural maintenance (VACUUM, index rebuild, bulk delete) while another
process holds ``db_path`` or a WAL sidecar; ``None`` when the store is provably quiet.

View File

@@ -436,9 +436,14 @@ class SessionMaintenanceMixin:
# Same admission `hermes sessions optimize` runs: VACUUM plus the TRUNCATE checkpoint
# retire the WAL generation a sibling writer (gateway, Desktop, dashboard, cron) still
# holds, and that is exactly the state every agent then refuses turns in (#110054).
# The foreign scan skips our own pid, so it is paired with the in-process arm: under
# one multiplexed process another live SessionDB generation for this same path is
# just as much a holder as another process would be.
# Automatic maintenance only ever SKIPS — a turn is never refused over housekeeping.
from hermes_state_holders import foreign_state_db_holders
holders = foreign_state_db_holders(self.db_path)
from hermes_state_holders import (
foreign_state_db_holders, in_process_state_db_holders)
holders = (foreign_state_db_holders(self.db_path)
+ in_process_state_db_holders(self.db_path, exclude=self))
if holders:
result["vacuum_skipped_holders"] = len(holders)
logger.debug(

View File

@@ -398,6 +398,31 @@ def close_all_under(directory: str | Path) -> int:
return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
def other_generations_for_path(
db_path: Path, *, exclude: Optional["SessionDB"] = None
) -> List[str]:
"""Describe every OTHER SessionDB generation THIS process holds for *db_path*.
The registry is path-keyed, so it can answer the in-process half of "is this store quiet?"
that a ``/proc`` descriptor scan structurally cannot: that scan skips our own pid, so it only
ever proves other PROCESSES are away. Retired generations count — they stay open for their
holders, and a VACUUM's TRUNCATE checkpoint retires the WAL generation underneath them.
"""
try:
path = Path(db_path).resolve()
except OSError:
path = Path(db_path)
with _lock:
candidates = [
("live", generation) for generation in _generations.values()
] + [("retired", generation) for generation in _retired.values()]
return [
f"in-process {kind} SessionDB generation (refcount {generation.refcount})"
for kind, generation in candidates
if generation.db is not exclude and generation.path == path
]
def live_shared_session_dbs() -> List["SessionDB"]:
"""Snapshot of every live (non-retired) shared SessionDB (refcounts untouched), for
in-process maintenance. A concurrent final release may close an instance, in which

View File

@@ -53,6 +53,12 @@ def two_homes(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
monkeypatch.setenv("HERMES_HOME", str(a))
monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False)
# The hermetic conftest pins ``hermes_state.DEFAULT_DB_PATH`` at one sandbox store whenever
# hermes_state is already imported, and that pin WINS over ``get_hermes_home()`` inside
# ``_default_db_path()`` — exactly the per-profile resolution these tests exist to prove.
# Restore the import-time sentinel so an argless ``acquire()`` resolves through the scope.
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH)
return a, b
@@ -128,6 +134,46 @@ def test_multiplexed_auto_archive_tick_sweeps_every_served_profile_store(two_hom
assert swept == [a / "state.db", b / "state.db"]
def test_multiplexed_maintenance_tick_prunes_every_served_profile_store(two_homes, monkeypatch):
"""Prune/VACUUM reaches each served profile's OWN state.db, under its OWN ``sessions:`` config.
Prune and VACUUM ran once in the gateway constructor against a handle pinned to the launch
home, so a multiplexed secondary's store was never pruned or vacuumed by anybody — it grew
without bound while the launch profile's ``retention_days`` decided whether it happened at all.
Real stores, real config files: nothing here is patched.
"""
from agent.secret_scope import set_multiplex_active
from hermes_state import SessionDB
homes = two_homes
for home in homes:
(home / "config.yaml").write_text(
"model:\n provider: nous\n"
"sessions:\n"
" auto_prune: true\n"
" retention_days: 0\n"
" min_interval_hours: 0\n"
" vacuum_after_prune: false\n",
encoding="utf-8")
db = SessionDB(db_path=home / "state.db")
db.create_session("old", "cli")
db.end_session("old", "done")
db.close()
set_multiplex_active(True)
try:
_run_60_ticks(SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True)))
finally:
set_multiplex_active(False)
for home in homes:
db = SessionDB(db_path=home / "state.db")
try:
assert db.get_session("old") is None, f"{home.name}'s store was never pruned"
finally:
db.close()
def test_single_profile_sync_ticks_run_once_against_the_process_home(two_homes, monkeypatch):
"""Control: a single-profile gateway (multiplex off) still runs each chore exactly once against
the process home — the named profile directory on disk is not visited."""

View File

@@ -0,0 +1,71 @@
"""Invariant: a profile's OWN ``sessions:`` config governs its store's auto-archive.
``_maybe_auto_archive_for_profile`` sweeps an arbitrary profile's store, but the config it
read came from a zero-arg ``load_config()`` — i.e. the PROCESS HERMES_HOME. On a host serving
several profiles, the launch profile's ``auto_archive`` / ``auto_archive_days`` silently
decided every other profile's retention.
Real stores, real ``config.yaml`` files and the real ``_check_gateway_running`` predicate;
only the profile -> home mapping is redirected at temp dirs.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from hermes_cli import web_server_sessions as wss
def _write_config(home: Path, *, auto_archive_days: int) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"sessions:\n"
" auto_archive: true\n"
f" auto_archive_days: {auto_archive_days}\n"
" min_interval_hours: 0\n",
encoding="utf-8",
)
@pytest.fixture
def two_profile_homes(tmp_path, monkeypatch):
"""Launch profile 'a' never archives; served profile 'b' archives immediately."""
from hermes_state import SessionDB
home_a, home_b = tmp_path / "a", tmp_path / "b"
_write_config(home_a, auto_archive_days=3650)
_write_config(home_b, auto_archive_days=0)
db = SessionDB(db_path=home_b / "state.db")
db.create_session("s1", "cli")
db.append_message("s1", "user", "hello")
db.close()
# Process home is the LAUNCH profile's.
monkeypatch.setenv("HERMES_HOME", str(home_a))
monkeypatch.setattr(
wss, "_session_db_path_for_profile",
lambda profile: (home_b if profile == "b" else home_a) / "state.db")
monkeypatch.setattr(wss, "_last_auto_archive_check", {})
return home_b
def _archived(db_path: Path) -> bool:
from hermes_state import SessionDB
db = SessionDB(db_path=db_path)
try:
return bool((db.get_session("s1") or {}).get("archived"))
finally:
db.close()
def test_auto_archive_uses_the_swept_profiles_own_retention_config(two_profile_homes):
home_b = two_profile_homes
wss._maybe_auto_archive_for_profile("b")
assert _archived(home_b / "state.db"), (
"profile b's store was swept with another profile's sessions.auto_archive_days")

View File

@@ -0,0 +1,67 @@
"""Automatic VACUUM is refused while THIS process holds another generation of the same store.
``foreign_state_db_holders`` skips ``os.getpid()``, so it can only ever prove other PROCESSES
are away. Under the one-process-per-host multiplexer the dangerous holder is usually a sibling
SessionDB generation inside this very process: the VACUUM's TRUNCATE checkpoint retires the WAL
generation that handle is still bound to.
"""
from __future__ import annotations
import shutil
import hermes_state_registry as registry
def _seed(path):
from hermes_state import SessionDB
db = SessionDB(db_path=path)
db.create_session("old", "cli")
db.append_message("old", "user", "hello")
db.end_session("old", "done")
db.close()
def _auto_maintenance(db):
# retention_days=0 makes the ended row prunable; the freelist floor is disabled so only a
# holder can stop the VACUUM.
return db.maybe_auto_prune_and_vacuum(
retention_days=0, min_interval_hours=0, min_vacuum_interval_days=0,
min_vacuum_freelist_ratio=-1.0)
def test_auto_vacuum_skips_while_this_process_holds_another_generation(tmp_path):
db_path = tmp_path / "state.db"
_seed(db_path)
first = registry.acquire(db_path)
try:
# Snapshot restore / recovery swap shape: the file is replaced, so the next acquire
# RETIRES `first` (still open, still held) and opens a fresh generation.
replacement = tmp_path / "replacement.db"
shutil.copy2(db_path, replacement)
replacement.replace(db_path)
second = registry.acquire(db_path)
try:
assert second is not first, "inode replacement did not mint a new generation"
result = _auto_maintenance(second)
assert result["pruned"] == 1
assert result["vacuumed"] is False, "VACUUM ran under a live in-process holder"
assert result.get("vacuum_skipped_holders")
finally:
registry.release(second)
finally:
registry.release(first)
# Control: the store is quiet once the other generation is released, so the same call VACUUMs.
only = registry.acquire(db_path)
try:
only.set_meta("last_auto_prune", "0")
only.create_session("old2", "cli")
only.end_session("old2", "done")
again = _auto_maintenance(only)
assert again["vacuumed"] is True
assert "vacuum_skipped_holders" not in again
finally:
registry.release(only)

View File

@@ -137,3 +137,21 @@ class TestUninspectableHolderInstanceScope:
holders = hermes_state_holders.foreign_state_db_holders(db_path)
assert [pid for pid, _ in holders] == [222], argv
assert holders[0][1].startswith("uninspectable holder:"), argv
def test_install_root_argv_still_holds_a_profile_store_under_it(self, tmp_path, monkeypatch):
"""One process per host serves EVERY profile, so argv naming only the install root does
not prove the process is another instance: it holds ``<root>/profiles/<name>/state.db``
too. A DIFFERENT install root is still proof."""
root = tmp_path / ".hermes"
db_path = root / "profiles" / "b" / "state.db"
db_path.parent.mkdir(parents=True)
multiplexer = ["hermes", "--home", str(root), "gateway", "run"]
other_install = ["hermes", "--home", "/home/demo/.hermes", "gateway", "run"]
_install_fake_proc(monkeypatch, db_path.parent, unreadable_pids=(222,))
_install_fake_argv(monkeypatch, {222: multiplexer})
assert [pid for pid, _ in hermes_state_holders.foreign_state_db_holders(db_path)] == [222]
_install_fake_proc(monkeypatch, db_path.parent, unreadable_pids=(222,))
_install_fake_argv(monkeypatch, {222: other_install})
assert hermes_state_holders.foreign_state_db_holders(db_path) == []