174ce8770d
Production `state.db` files accumulate zero-message "open" gateway session rows carrying test-fixture identities (`chat-1` / `user-1` / `wx-chat`), with matching `gateway_routing` scopes pointing at `pytest-of-*` temp directories. The escape is structural. Hermetic isolation rides entirely on the process environment: `HERMES_HOME` says *where* to write, `PYTEST_CURRENT_TEST` / `PYTEST_VERSION` say *whether the guard is armed*. Both travel in the same carrier, so a child spawned with a rebuilt environment loses them together — it resolves the developer's real `state.db` *and* silences the only check that would have stopped it, in one step. The guard is a no-op in precisely the situation it was written for. Back the env probe with process ancestry, which survives an env rebuild: * `_process_looks_like_pytest()` matches a pytest launcher by argv token basename, so `/tmp/pytest-of-dev/...` paths in real argv cannot false-positive, and an unreadable process is never assumed to be a test. * `_has_pytest_ancestor()` walks parents via psutil, memoised, and fails open when psutil is unavailable — a real `hermes` run pays for at most one walk and keeps the previous behaviour if the walk errors. * `_in_test_context()` checks env first (two dict lookups, covers the in-process case) and only then ancestry. `_STATE_DB_GUARD_BYPASS` is a module global and cannot cross a process boundary, so ancestry-armed children would have had no way to opt out at all; `HERMES_STATE_DB_GUARD_BYPASS=1` is the env-carried twin. Also sweeps the rows already written. Bulk prune/archive cannot reach them: their shared selector is pinned to `ended_at IS NOT NULL` so a live session is never picked, which permanently excludes every never-closed row. Adds a narrower selector — keyed, still open, and with no messages, tokens, tool calls, API calls, activity or title — behind `hermes sessions prune --never-active` (default floor 30 days, honours --dry-run/--yes). Routing entries naming a deleted row go with it, so the gateway is never left resuming a session id that no longer exists; `pinned` and `archived` rows are excluded as explicit user intent. Closes #82770