mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
With one always-on gateway per host, `hermes sessions prune` always found
the owner holding state.db and refused (live smoke LS-F7). The only ways
past it were `gateway stop`, which the next client undoes by restarting
the gateway, or `--force`, which makes the CLI a second writer under the
owner. `hermes sessions delete` already worked against the same gateway.
Now, when a ready gateway serves the home (discovery only, it never
starts one), prune opens the store read-only for its preview (the pinned
note, the open-session note, `--dry-run`, the confirm prompt; all
unchanged). It then sends the filters it built to the owner as
`session.prune`. The owner deletes through `prune_sessions` /
`retire_prunable`, the path housekeeping uses, so busy sessions and the
logical owner of a retained reset child are skipped. It then drops the
deleted ids from its own memory (routes, launch secrets, live sessions,
cached agents) through the helper canonical delete now shares
(`retire_live_sessions`). `--never-active` goes the same way. With no
gateway running, prune is the local command it was, holder scan and
`--force` included.
`session.prune` deletes across every principal's rows, so it is open
only to the native local operator ticket from this host's private
control socket. A viewer or a remote/browser grant gets
`permission_denied`. The contract is declared and the generated TS and
OpenRPC files are regenerated. The CLI help and the two user-guide
passages that promised a refusal are updated.
Red on 30b63bda20 (all touched sources swapped back), green after:
tests/hermes_cli/test_sessions_prune_via_owner.py, 3 tests. A real
foreign process holds the store; prune applies through the owner with
the right rows, keeps the reset owner and its child's history, the CLI
opened only read-only, and non-operator grants are refused.
Live, a real `gateway run` in a fake HOME. Before: `prune --yes` printed
"Refusing ... PID <gateway>", rc=1. After: --source cron pruned 1; a bare
--older-than 0 pruned 2 and kept the reset owner and its open child; the
next turn on that session completed; strace shows the CLI opened
state.db O_RDONLY only. With no gateway the local prune still works and
does not start one.
The noqa on canonical.py's trailing import is removed. Neither ruff
config flags that import without it, and strict ruff's RUF100 rejected
the directive, which blocked the lefthook.
Review: https://github.com/NousResearch/hermes-agent/pull/106742
(cherry picked from commit 1e2dd5a49f04439b53a248aab24df9b10f4c7f9e)
62 lines
3.0 KiB
Python
62 lines
3.0 KiB
Python
"""``session.prune``: the owner runs `hermes sessions prune` for its own store.
|
|
|
|
The CLI computes the filters and shows the preview from a read-only view; the delete itself is
|
|
this owner's write, through the same ``prune_sessions`` / ``retire_prunable`` path housekeeping
|
|
uses (busy sessions and the logical owner of a retained reset child are skipped). A CLI writing
|
|
the store under a live gateway would be a second writer, so it never does.
|
|
"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from hermes_state_runtime import RuntimeStoreError
|
|
|
|
_SCALARS = (type(None), bool, int, float, str)
|
|
|
|
|
|
def _filters(raw):
|
|
from hermes_state_maintenance import _PRUNE_FILTER_NAMES
|
|
if (not isinstance(raw, dict) or set(raw) - (_PRUNE_FILTER_NAMES | {'older_than_days'})
|
|
or not all(isinstance(value, _SCALARS) for value in raw.values())):
|
|
raise RuntimeStoreError('invalid_params')
|
|
return dict(raw)
|
|
|
|
|
|
async def prune(connection, ref, params):
|
|
authority, actor = connection.authority, connection.actor
|
|
# Store-wide deletion across every principal's rows: the local operator only (a ticket from
|
|
# this host's private control socket), never a remote or browser grant.
|
|
if not connection.native_owner or 'session:operator' not in actor.capabilities:
|
|
raise RuntimeStoreError('permission_denied')
|
|
if actor.profile_id != authority.profile_id:
|
|
raise RuntimeStoreError('profile_mismatch')
|
|
never_active = params.get('never_active_days')
|
|
if ('filters' in params) == (never_active is not None) or (
|
|
never_active is not None and (isinstance(never_active, bool) or not isinstance(never_active, (int, float))
|
|
or never_active < 0)):
|
|
raise RuntimeStoreError('invalid_params')
|
|
filters = _filters(params['filters']) if 'filters' in params else None
|
|
authority._require_admission_open()
|
|
from gateway.session_runtime_workers import track_mutation
|
|
task = track_mutation(authority, _apply(authority, filters, never_active))
|
|
return await asyncio.shield(task)
|
|
|
|
|
|
async def _apply(authority, filters, never_active):
|
|
from hermes_constants import get_hermes_home
|
|
from gateway.session_mutations import retire_live_sessions
|
|
# Same transcript dir the CLI's local prune unlinks under (dispatch runs in the owner's scope).
|
|
sessions_dir = Path(get_hermes_home()) / 'sessions'
|
|
deleted_ids = []
|
|
if filters is None:
|
|
deleted, routing, skipped = await asyncio.to_thread(
|
|
authority.db.prune_never_active_keyed_sessions, older_than_days=float(never_active),
|
|
sessions_dir=sessions_dir, deleted_ids=deleted_ids)
|
|
result = {'deleted': deleted, 'routing_deleted': routing, 'skipped': skipped}
|
|
else:
|
|
deleted = await asyncio.to_thread(
|
|
authority.db.prune_sessions, sessions_dir=sessions_dir, exclude_active_write_guards=True,
|
|
deleted_ids=deleted_ids, **filters)
|
|
result = {'deleted': deleted}
|
|
retire_live_sessions(authority, deleted_ids)
|
|
return result
|