From a2c0af430c4ce458727dfd61445ca2e2370f72fa Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:14:16 -0700 Subject: [PATCH] fix(state): per-profile store housekeeping under one multiplexed process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gateway/run.py | 67 ++++++++--------- hermes_cli/web_server_sessions.py | 17 ++++- hermes_state_holders.py | 39 +++++++++- hermes_state_maintenance.py | 9 ++- hermes_state_registry.py | 25 +++++++ .../test_housekeeping_profile_scope.py | 46 ++++++++++++ ..._web_server_auto_archive_profile_config.py | 71 +++++++++++++++++++ ...test_auto_vacuum_in_process_holder_gate.py | 67 +++++++++++++++++ .../test_fts_holder_instance_scope.py | 18 +++++ 9 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 tests/hermes_cli/test_web_server_auto_archive_profile_config.py create mode 100644 tests/hermes_state/test_auto_vacuum_in_process_holder_gate.py diff --git a/gateway/run.py b/gateway/run.py index 188e776177..0321b68e12 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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)), diff --git a/hermes_cli/web_server_sessions.py b/hermes_cli/web_server_sessions.py index a7a0472b07..e7ca183d5b 100644 --- a/hermes_cli/web_server_sessions.py +++ b/hermes_cli/web_server_sessions.py @@ -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: diff --git a/hermes_state_holders.py b/hermes_state_holders.py index bb739004f3..abac7d7605 100644 --- a/hermes_state_holders.py +++ b/hermes_state_holders.py @@ -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 + ``/profiles//state.db`` while its argv mentions only + ````. 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) + # ``/profiles//state.db`` -> ````; 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. diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 5da04fd6fc..a87b6f5460 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -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( diff --git a/hermes_state_registry.py b/hermes_state_registry.py index 5b0b594ee8..fb6ffc4834 100644 --- a/hermes_state_registry.py +++ b/hermes_state_registry.py @@ -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 diff --git a/tests/gateway/test_housekeeping_profile_scope.py b/tests/gateway/test_housekeeping_profile_scope.py index a201eb562c..328a024572 100644 --- a/tests/gateway/test_housekeeping_profile_scope.py +++ b/tests/gateway/test_housekeeping_profile_scope.py @@ -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.""" diff --git a/tests/hermes_cli/test_web_server_auto_archive_profile_config.py b/tests/hermes_cli/test_web_server_auto_archive_profile_config.py new file mode 100644 index 0000000000..a3dc3aa756 --- /dev/null +++ b/tests/hermes_cli/test_web_server_auto_archive_profile_config.py @@ -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") diff --git a/tests/hermes_state/test_auto_vacuum_in_process_holder_gate.py b/tests/hermes_state/test_auto_vacuum_in_process_holder_gate.py new file mode 100644 index 0000000000..2882bf8f1b --- /dev/null +++ b/tests/hermes_state/test_auto_vacuum_in_process_holder_gate.py @@ -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) diff --git a/tests/hermes_state/test_fts_holder_instance_scope.py b/tests/hermes_state/test_fts_holder_instance_scope.py index 6f3f7f9dd8..193aa0ffae 100644 --- a/tests/hermes_state/test_fts_holder_instance_scope.py +++ b/tests/hermes_state/test_fts_holder_instance_scope.py @@ -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 ``/profiles//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) == []