fix(gateway): close a profile session's row in its own profile db - #324
Merged
Merged
Conversation
`_finalize_session` resolved the session store with `_get_db()`, the process-global SessionDB handle pinned to the gateway's launch home (`hermes_state.DEFAULT_DB_PATH` is a module-level constant evaluated at import). Every neighbouring write in the file already routes through the profile-aware `_session_db(session)`; this one did not. Same defect class as #323's /undo branch, one function away. A session created with `profile: "<other>"` keeps its row in `<root>/profiles/<other>/state.db`, so the launch handle was the wrong file, with two consequences: 1. `db.get_session(session_id)` returned None, so `source` was "" and `_is_gateway_owned_source("")` was False — `_tui_owns_lifecycle` became True for every profile session, including a Telegram/Discord one the desktop is only viewing. The NousResearch#60609 Groundhog Day guard was INERT for profile sessions. 2. `db.end_session(session_id, "tui_close")` ran `UPDATE ... WHERE id = ? AND ended_at IS NULL` against the launch db and matched 0 rows — a silent no-op. The row was closed later as `agent_close` by agent teardown (run_agent.py), a reason `find_latest_gateway_session_for_peer` treats as *recoverable*, so a cleanly-closed session stayed stale-routable. Route the write through `_session_db(session)`: `<profile_home>/state.db` when the session carries a `profile_home`, the shared `_get_db()` handle otherwise, so the ordinary single-profile path is unchanged and the context manager closes the per-profile handle on exit. Making the guard live does not reintroduce NousResearch#60609 — it extends the protection it was written for. For a gateway-owned profile session the db outcome is identical (the row still ends as the recoverable `agent_close`, previously by accident of the 0-row UPDATE, now because the guard fires). The one behavioural change there is the NousResearch#55578 delegation interrupt: `_tui_owns_lifecycle` is now False, so closing a viewer tab no longer interrupts the gateway's background subagents by durable session_key — exactly what the comment at that call site says should happen. TUI/desktop-owned profile sessions are unaffected by that branch and simply stop leaving ghost rows in /resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OmarB97
pushed a commit
that referenced
this pull request
Aug 2, 2026
`_get_db()` is a cached module global bound to `hermes_state.DEFAULT_DB_PATH`, evaluated at *import* time from the launch profile's home. A session created with `profile: "<other>"` keeps its rows in `<root>/profiles/<other>/state.db`, reached through the profile-aware `_session_db(session)` helper. #323 (/undo) and #324 (`_finalize_session`) each fixed one site of this. This audits every remaining `_get_db()` call in `tui_gateway/server.py` that runs with a live `session` dict in hand and fixes the twelve that are wrong. Reproduced against real SessionDB files under a temp home with two profiles: - `session.branch` died outright with "branch failed: FOREIGN KEY constraint failed" — the branch row is an FK child of the parent's row, which the launch db does not have. The child now inherits the parent's profile end to end (row, agent handle, `profile_home`, and the `_profile_home_bound` binding around `_resolve_model()` that #325 established), because fixing only the write would have left its rows in one db and its live session pointing at another. - `prompt.submit`'s edit truncation raised the same FK error, which also skipped `deactivate_turn_outcomes_from_ordinal` in the same `try`, and left the profile db holding the full pre-edit history — so resuming brought the edited-away turns back. Same class as #323. - Both notification-ownership checks resolved the compression chain in the wrong db, so a post-compression profile session stopped recognising its own pre-compression dispatches and the fail-closed gate of NousResearch#55578 dropped the delegation completion. Both now share `_resolve_session_lineage_key`. - `/history` replaced the live window with an empty launch-db read and answered "No conversation history yet." mid-conversation; `/context` under-reported. - Titles: the read paths, the post-turn `pending_title` apply, and `maybe_auto_title` all used the launch db, so a profile session's title was either invisible or never persisted at all. - `/status` found no row, so it had no Title and reported Created/Last Activity as "now"; `_background_agent_kwargs` wrote a profile session's background transcript into the launcher's state.db. Where the handle must outlive the call — `maybe_auto_title`'s daemon thread, `_background_agent_kwargs`' background agent, and the notification poll loop — the fix uses the agent's own long-lived handle rather than a `_session_db()` one that would be closed on block exit (or churn schema write locks per poll), matching `_persist_live_session_runtime`. Left launch-scoped deliberately: `session.list`, `session.most_recent`, `session.delete`, `projects.*` and `insights.get` hold no session and take no profile — the desktop routes them per-profile by connecting to that profile's gateway (apps/desktop/src/store/projects.ts:252). Verified with 20 tests over real SessionDB files under a temp HERMES_HOME with two profiles, no db mocks; 16 fail on the parent commit and 4 are controls that pass on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 tasks
OmarB97
added a commit
that referenced
this pull request
Aug 2, 2026
… db (#333) `_get_db()` is a cached module global bound to `hermes_state.DEFAULT_DB_PATH`, evaluated at *import* time from the launch profile's home. A session created with `profile: "<other>"` keeps its rows in `<root>/profiles/<other>/state.db`, reached through the profile-aware `_session_db(session)` helper. #323 (/undo) and #324 (`_finalize_session`) each fixed one site of this. This audits every remaining `_get_db()` call in `tui_gateway/server.py` that runs with a live `session` dict in hand and fixes the twelve that are wrong. Reproduced against real SessionDB files under a temp home with two profiles: - `session.branch` died outright with "branch failed: FOREIGN KEY constraint failed" — the branch row is an FK child of the parent's row, which the launch db does not have. The child now inherits the parent's profile end to end (row, agent handle, `profile_home`, and the `_profile_home_bound` binding around `_resolve_model()` that #325 established), because fixing only the write would have left its rows in one db and its live session pointing at another. - `prompt.submit`'s edit truncation raised the same FK error, which also skipped `deactivate_turn_outcomes_from_ordinal` in the same `try`, and left the profile db holding the full pre-edit history — so resuming brought the edited-away turns back. Same class as #323. - Both notification-ownership checks resolved the compression chain in the wrong db, so a post-compression profile session stopped recognising its own pre-compression dispatches and the fail-closed gate of NousResearch#55578 dropped the delegation completion. Both now share `_resolve_session_lineage_key`. - `/history` replaced the live window with an empty launch-db read and answered "No conversation history yet." mid-conversation; `/context` under-reported. - Titles: the read paths, the post-turn `pending_title` apply, and `maybe_auto_title` all used the launch db, so a profile session's title was either invisible or never persisted at all. - `/status` found no row, so it had no Title and reported Created/Last Activity as "now"; `_background_agent_kwargs` wrote a profile session's background transcript into the launcher's state.db. Where the handle must outlive the call — `maybe_auto_title`'s daemon thread, `_background_agent_kwargs`' background agent, and the notification poll loop — the fix uses the agent's own long-lived handle rather than a `_session_db()` one that would be closed on block exit (or churn schema write locks per poll), matching `_persist_live_session_runtime`. Left launch-scoped deliberately: `session.list`, `session.most_recent`, `session.delete`, `projects.*` and `insights.get` hold no session and take no profile — the desktop routes them per-profile by connecting to that profile's gateway (apps/desktop/src/store/projects.ts:252). Verified with 20 tests over real SessionDB files under a temp HERMES_HOME with two profiles, no db mocks; 16 fail on the parent commit and 4 are controls that pass on both. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
_finalize_session(tui_gateway/server.py) wrote a session'sended_atto the wrong database for profile-scoped sessions. It resolved the store with_get_db()— the process-globalSessionDBhandle pinned to the gateway's launch home, sincehermes_state.DEFAULT_DB_PATHis a module-level constant evaluated at import. Every neighbouring session write in that file already routes through the profile-aware_session_db(session)helper; this one did not. Same defect class as #323's/undobranch, one function away.A session created with
profile: "<other>"keeps its row in<root>/profiles/<other>/state.db(session.resume opens it explicitly atserver.py:1619), so the launch handle pointed at the wrong file entirely:db.get_session(session_id)returnedNone, sosourcewas"",_is_gateway_owned_source("")wasFalse, and_tui_owns_lifecyclebecameTruefor every profile session — including a Telegram/Discord session the desktop is only a viewer of.db.end_session(session_id, "tui_close")ranUPDATE ... WHERE id = ? AND ended_at IS NULLagainst the launch db and matched 0 rows. The row was closed later asagent_closeby agent teardown (run_agent.py) — a reasonfind_latest_gateway_session_for_peertreats as recoverable (hermes_state.py:2504), so a cleanly-closed profile session stayed stale-routable and lingered as a ghost row in/resume.Routing the write through
_session_db(session)fixes both: it opens<profile_home>/state.dbwhen the session carries aprofile_home, borrows the shared_get_db()handle otherwise (so the ordinary single-profile path is byte-for-byte unchanged), and closes the per-profile handle on exit.Does making the guard live reintroduce NousResearch#60609? No — it extends the protection that guard was written for (
git log -p -S _is_gateway_owned_source→ f5ef7ee). Checked both consequences for a gateway-owned profile session:agent_closefrom agent teardown — previously by accident of the 0-rowUPDATE, now because the guard actually fires. Nothing that was reachable becomes unreachable._tui_owns_lifecycleis nowFalsefor gateway-owned profile sessions, so closing a viewer tab no longer interrupts the gateway's in-flight background subagents by durablesession_key(the tab's own dispatches are still interrupted byorigin_ui_session_id). That is exactly what the comment at that call site says should happen — the non-profile path has always behaved this way.TUI/desktop-owned profile sessions are unaffected by that branch; they simply stop leaving ghost rows.
Related Issue
Found while tracing dead desktop spawns; #321 documented it as deliberately out of scope there. No separate issue filed.
Type of Change
Changes Made
tui_gateway/server.py—_finalize_sessionacquires the store viawith _session_db(session)instead of_get_db(), so the_is_gateway_owned_sourcelookup and theend_sessionwrite both hit the session's own profile db. The ws_orphan_reap kills gateway-originated sessions, causing Groundhog Day routing loop NousResearch/hermes-agent#60609 comment is preserved; a new comment records why the launch handle was wrong.tests/tui_gateway/test_finalize_session_profile_db.py(new) — five tests against realSessionDBfiles under a temp HERMES_HOME with two profiles (no db mocks): the profile row is ended withtui_closeand the launch row is untouched; a gateway-owned profile row is not ended; a session with no profile binding still ends in the launch db (control); and both halves of the now-live guard on the delegation interrupt. Both dbs seed a row under the same id so the write target is unambiguous.tests/test_tui_gateway_server.py—test_finalize_session_profile_session_ends_in_profile_db, following the"launch_update" not in capturedcontract the existingtest_session_resume_profile_uses_profile_db_cwd/test_session_cwd_set_profile_session_updates_profile_dbtests use for the resume and cwd paths. The close path had no such cover.How to Test
tui_gateway/server.pygives:test_profile_session_is_ended_in_its_own_profile_db→assert row["ended_at"] is not None→assert None is not Nonetest_gateway_owned_profile_session_is_not_ended→ the launch row moved:assert 1785685065.009598 is Nonetest_gateway_owned_profile_session_keeps_gateway_delegations→assert 'sess-profile-1' == ''test_finalize_session_profile_session_ends_in_profile_db→KeyError: 'profile_lookup'HERMES_HOMEwith two profiles, drivingsession.create(profile="worker")→ first-message row persist →session.close, then reading bothstate.dbfiles with plainsqlite3(see log below).scripts/run_tests.sh tests/tui_gateway/ tests/test_tui_gateway_server.py tests/test_lazy_session_regressions.py -q→ 45 files, 888 passed, 0 failed. Plustests/test_tui_gateway_ws.py tests/test_hermes_state.py tests/tools/test_async_delegation.py tests/hermes_cli/test_resolve_last_session.py tests/hermes_state/test_resolve_resume_session_id.py tests/gateway/test_webhook_session_close.py→ 446 passed, 0 failed.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the affected areas only (1,334 tests across the gateway/session-lifecycle files listed above, 0 failed); the full suite is not green on this machine independent of this changeDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
Same script, same steps, real
sqlite3read of both dbs aftersession.closereturned{'closed': True}.Before (parent commit) — the profile row never closes:
After:
The
telegramrow staying open is correct in both runs — but for different reasons, which is the point of the guard: before, because the write missed the file; after, because_is_gateway_owned_sourcefinally reads a realsource.🤖 Generated with Claude Code