fix(gateway): one served profile's failing chore no longer skips the rest (#120268)

_for_each_served_profile ran a housekeeping body once per served profile
with no boundary between them, and _housekeeping_chore only catches at the
tick level. One profile's unreadable store or broken .env therefore ended
the loop, and every profile after it lost its state.db archive/prune,
curator, skill-sync and MCP reconcile pass on every tick.

That state is reachable: _init_session_db tolerates a failed launch store
and keeps running, and hermes serve defers each served profile's
auto-archive to this loop (#117746), so a satellite behind a broken launch
store had no sweeper at all.

Each profile now gets its own try/except, logged at debug like
_housekeeping_chore.

Co-authored-by: Baris Sencan <b.sencan@equalsmoney.com>
This commit is contained in:
Austin Pickett
2026-09-23 10:19:39 -04:00
committed by GitHub
parent bd945ec384
commit d2ef7db751
2 changed files with 74 additions and 2 deletions

View File

@@ -305,8 +305,15 @@ def _for_each_served_profile(runner, body) -> None:
body("default")
return
for profile_name, profile_home in _multiplex_profile_homes(config):
with _profile_runtime_scope(Path(profile_home)):
body(str(profile_name))
# One boundary per profile: callers (``_housekeeping_chore``) catch only at the tick level,
# so one profile's unreadable store or broken .env abandoned every profile after it, on
# every tick. The launch store failing is reachable (``_init_session_db`` tolerates it and
# keeps running), and serve defers every served profile's sweep to this loop.
try:
with _profile_runtime_scope(Path(profile_home)):
body(str(profile_name))
except Exception as exc:
logger.debug("Housekeeping for profile %s skipped: %s", profile_name, exc)
def profile_scoped_chore(runner, chore):

View File

@@ -178,6 +178,71 @@ def test_multiplexed_maintenance_tick_prunes_every_served_profile_store(two_home
db.close()
def test_a_failing_profile_does_not_strand_the_profiles_after_it(two_homes, monkeypatch):
"""One served profile's broken store must not cost every profile after it its maintenance.
``_housekeeping_chore`` catches at the tick level only, so the launch store raising in
``acquire()`` (reachable: ``_init_session_db`` tolerates a failed primary store and keeps
running) ended the per-profile loop before B, on every tick. Serve defers each served
profile's sweep to this loop, so B had no archiver at all.
"""
import hermes_state_registry as registry
from agent.secret_scope import set_multiplex_active
from hermes_constants import get_hermes_home
from hermes_state import SessionDB
a, b = two_homes
swept: list = []
real_acquire = registry.acquire
def _acquire(*args, **kwargs):
if get_hermes_home() == a:
raise OSError("launch store unavailable")
return real_acquire(*args, **kwargs)
monkeypatch.setattr(registry, "acquire", _acquire)
monkeypatch.setattr(
SessionDB, "maybe_auto_archive", lambda self, **kw: swept.append(Path(self.db_path)))
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda *args, **kwargs: {"sessions": {"auto_archive": True, "min_interval_hours": 0}})
set_multiplex_active(True)
try:
_run_60_ticks(SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True)))
finally:
set_multiplex_active(False)
assert swept == [b / "state.db"]
assert get_hermes_home() == a
def test_profile_scope_setup_failure_restores_the_callers_home(two_homes, monkeypatch):
"""A profile scope whose secret hydration raises must not leave its home installed.
The home override was set before hydration and only reset in the ``finally`` around the
``yield``, so a raising ``.env`` load left the housekeeping thread (or a turn's context)
resolving ``get_hermes_home()`` to the failed profile for every later unscoped read.
"""
from agent.secret_scope import current_secret_scope
from hermes_constants import get_hermes_home
a, b = two_homes
scope_before = current_secret_scope()
def _boom(home):
raise OSError(f"cannot read {home}/.env")
monkeypatch.setattr(gateway_run, "_load_profile_secret_scope", _boom)
with pytest.raises(OSError):
with gateway_run._profile_runtime_scope(b):
pass
assert get_hermes_home() == a
assert current_secret_scope() == scope_before
def test_prune_unlinks_transcripts_under_the_configured_sessions_dir(two_homes, tmp_path):
"""``gateway.sessions_dir`` governs the LAUNCH profile's transcripts; others use their own home.