fix(sessions): release active-session leases against their acquisition registry
A gateway active-session lease is acquired against the root HERMES_HOME, but release_active_session()/transfer_active_session() re-resolved the registry path from the *current* HERMES_HOME. Under native multiplex a routed turn runs agent cleanup inside _profile_runtime_scope, so the release looked under the named profile while the root entry stayed alive — after max_concurrent_sessions routed turns every new session was rejected with 'Hermes is at the active session limit' (#85431). Pin state/lock paths on the lease at acquisition time and prefer them on release and transfer. Fixes #85431.
This commit is contained in:
@@ -261,6 +261,14 @@ class ActiveSessionLease:
|
||||
surface: str
|
||||
enabled: bool = True
|
||||
released: bool = False
|
||||
# Registry paths pinned at acquisition time. A lease acquired under the
|
||||
# root ``HERMES_HOME`` must release against the same registry even when
|
||||
# ``release()`` runs inside a profile home override (native multiplex
|
||||
# routes turns under ``_profile_runtime_scope``), otherwise the root
|
||||
# entry survives until process exit and the session cap fills with
|
||||
# phantom leases (#85431).
|
||||
state_path: Optional[Path] = None
|
||||
lock_path: Optional[Path] = None
|
||||
|
||||
def release(self) -> None:
|
||||
if self.released or not self.enabled:
|
||||
@@ -331,13 +339,18 @@ def try_acquire_active_session(
|
||||
lease_id=lease_id,
|
||||
session_id=str(session_id),
|
||||
surface=str(surface),
|
||||
state_path=state_path,
|
||||
lock_path=_lock_path(),
|
||||
), None
|
||||
|
||||
|
||||
def release_active_session(lease: ActiveSessionLease) -> None:
|
||||
state_path = _state_path()
|
||||
# Prefer the registry the lease was acquired against: the caller may be
|
||||
# running under a profile HERMES_HOME override (#85431).
|
||||
state_path = lease.state_path or _state_path()
|
||||
lock_path = lease.lock_path or _lock_path()
|
||||
try:
|
||||
with _FileLock(_lock_path()):
|
||||
with _FileLock(lock_path):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
kept = [
|
||||
entry
|
||||
@@ -366,8 +379,9 @@ def transfer_active_session(
|
||||
lease.session_id = new_session_id
|
||||
return True
|
||||
|
||||
state_path = _state_path()
|
||||
with _FileLock(_lock_path()):
|
||||
state_path = lease.state_path or _state_path()
|
||||
lock_path = lease.lock_path or _lock_path()
|
||||
with _FileLock(lock_path):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
updated = False
|
||||
for entry in entries:
|
||||
|
||||
@@ -167,3 +167,76 @@ def test_release_orphaned_leases_reclaims_only_unowned_own_pid_entries(tmp_path,
|
||||
for entry in active_sessions.active_session_registry_snapshot()
|
||||
) == ["kept", "other"]
|
||||
assert orphan is not None
|
||||
|
||||
|
||||
def test_release_under_profile_home_override_targets_acquisition_registry(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression for #85431: a lease acquired against the root HERMES_HOME
|
||||
must release from the root registry even when ``release()`` runs inside a
|
||||
profile home override (native multiplex runs agent cleanup under
|
||||
``_profile_runtime_scope``). Before the fix the root entry survived and
|
||||
the session cap filled with phantom leases."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
lease, error = active_sessions.try_acquire_active_session(
|
||||
session_id="agent:worker:telegram:dm:synthetic",
|
||||
surface="gateway:telegram",
|
||||
config={"max_concurrent_sessions": 2},
|
||||
)
|
||||
assert lease is not None and error is None
|
||||
root_registry = root / "runtime" / "active_sessions.json"
|
||||
assert root_registry.exists()
|
||||
|
||||
token = set_hermes_home_override(str(profile))
|
||||
try:
|
||||
lease.release()
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert lease.released is True
|
||||
remaining = active_sessions._read_entries(root_registry)
|
||||
assert remaining == []
|
||||
# No phantom registry created under the profile home.
|
||||
assert not (profile / "runtime" / "active_sessions.json").exists()
|
||||
|
||||
|
||||
def test_transfer_under_profile_home_override_targets_acquisition_registry(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Sibling site of #85431: transfer must also update the registry the
|
||||
lease was acquired against, not one resolved from the current override."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
lease, error = active_sessions.try_acquire_active_session(
|
||||
session_id="before",
|
||||
surface="gateway:telegram",
|
||||
config={"max_concurrent_sessions": 2},
|
||||
)
|
||||
assert lease is not None and error is None
|
||||
|
||||
token = set_hermes_home_override(str(profile))
|
||||
try:
|
||||
assert active_sessions.transfer_active_session(lease, session_id="after")
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
root_registry = root / "runtime" / "active_sessions.json"
|
||||
entries = active_sessions._read_entries(root_registry)
|
||||
assert [entry["session_id"] for entry in entries] == ["after"]
|
||||
|
||||
Reference in New Issue
Block a user