fix(gateway): read and write a live session's rows in its own profile db - #333
Merged
Merged
Conversation
`_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
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?
_get_db()is a cached module global bound tohermes_state.DEFAULT_DB_PATH, evaluated at import time from the launch profile's home. A session created withprofile: "<other>"(the desktop's app-global remote mode) 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 intui_gateway/server.pythat runs with a livesessiondict in hand. Twelve were genuinely wrong; eight were already safe; seven are deliberately launch-scoped and are left alone.Every failure below was reproduced against real
SessionDBfiles under a temp home with two profiles — the failing line is named, not inferred.session.branch— hard failure,/branchunusable under a profile. The branch row is an FK child of the parent's row (parent_session_id). Created against the launch db, which has no parent row,create_sessionraised and the handler returnedbranch failed: FOREIGN KEY constraint failed. Fixing only the write would have swapped one broken state for another — the child's rows would sit in the profile db while its live session dict had noprofile_home, so its agent and every later_session_db()read would go straight back to the launch db. The child now inherits the parent's profile end to end (row, agent handle,profile_home, and the_profile_home_boundbinding around_resolve_model()— the same mis-binding #325 fixed for_ensure_session_db_row, made permanent here by the row's COALESCE upsert).prompt.submithistory truncation — silent data loss on a message edit.db.replace_messages(session_key, truncated)raisedFOREIGN KEY constraint failedon the launch db. Theexceptswallowed it, which also skippeddeactivate_turn_outcomes_from_ordinalin the sametry, and the profile db kept the full pre-edit history — so resuming the session brought the edited-away turns straight back. Same class as the/undorewind in #323.Notification ownership — dropped delegation completions. Both
_session_owns_notification_eventand_notification_event_belongs_elsewheremap an event'ssession_keyto its continuation tip viaresolve_resume_session_id, so an event captured before compression still finds the live session. The launch db has no such row, so it silently returned the raw key: a post-compression profile session stopped recognising its own pre-compression dispatches, and with the fail-closed gate of NousResearch#55578 in front the poller loggedDropping unowned async_delegation notificationand threw the result away./historyshowed nothing._format_live_history_outputassignshistory = db.get_messages_as_conversation(...)unconditionally, so an empty launch-db read replaced the live in-memory window and/historyanswered "No conversation history yet." mid-conversation. Thesession.historyRPC has used_session_dbfor this exact read all along; the live slash-command path never caught up./context(_format_live_context_output) is the sibling — it has an in-memory fallback, so it degraded quietly instead, under-reporting every message that only exists in the db (i.e. everything before a compression).Titles — a profile session could never keep one. Four sites, one story.
session.title's set path already reached the profile db, but only by accident: the launchUPDATEmatched 0 rows and fell through to the_ensure_session_db_row+_session_dbrecovery added in NousResearch#47987. Everything that reads or re-applies it did not — the read path answered""for a title sitting in<profile_home>/state.db;_session_live_titlefed that same""into everysession.info; the post-turnpending_titleapply gotFalsefrom a 0-rowUPDATE, so a title given atsession.createwas retried every turn and never persisted; andmaybe_auto_titlewrote to the launch db, leaving profile sessions permanently untitled in the sidebar./statusshowed invented timestamps.db.get_session(key)returned no row, so there was noTitle:line andCreated/Last Activityfell back todatetime.now()on every call.Background/preview subagents wrote to the wrong profile. Every other field in
_background_agent_kwargsis inherited from the parent agent;session_dbwas not. Both callers passsession["agent"], so a profile session'sprompt.backgroundtranscript landed in the launcher'sstate.db— visible in the wrong profile's session list and missing from its own.Which handle each fix uses
_session_db(session)where the read/write is synchronous. Where the handle must outlive the call it is the agent's own instead (getattr(agent, "_session_db", None) or _get_db(), the existing idiom in_persist_live_session_runtime):maybe_auto_titlewrites from a daemon thread,_background_agent_kwargsfeeds an agent that runs on its own thread, and the notification helpers run in a poll loop. A_session_dbhandle would be closed on block exit in the first two, and in the third a fresh read-writeSessionDBper event would take the profile db's schema write lock at poll frequency and contend with that profile's live backend.Left alone deliberately
session.list,session.most_recent,session.delete,projects.*,insights.get— no live session, noprofileparam. These are the backend's own view of its own profile, and the desktop routes them that way on purpose (apps/desktop/src/store/projects.ts:252— "Projects are per-profile, so they intentionally follow the active gateway just like the session list does."). Cross-profile aggregation is a separate read-only path (SessionDB(read_only=True)intools/session_search_tool.py)._ensure_session_db_row,session.resume,_make_agent,_init_session,_persist_live_session_runtime,_persist_live_session_system_prompt, and_session_dbitself — each already picks the profile handle above the_get_db()line, which is only their launch fallback.Related Issue
Follow-up to #323 / #324 / #325 (same defect class). No separate issue.
Type of Change
Changes Made
tui_gateway/server.py:session.branch— open the parent's profile db, bind its home for the row write, hand the handle to the child agent and_init_session, stampprofile_homeon the child session; release the handle on the error paths.prompt.submit—replace_messages/deactivate_turn_outcomes_from_ordinalvia_session_db(session)._resolve_session_lineage_key(session, evt_key)— one compression-chain resolver shared by_notification_event_belongs_elsewhereand_session_owns_notification_event._format_live_history_output,_format_live_context_output,_session_live_title,session.title,session.status— read through_session_db(session)._run_prompt_submit—pending_titlevia_session_db(session);maybe_auto_titlegets the agent's own handle._background_agent_kwargs—session_dbinherited from the parent agent.tests/tui_gateway/test_profile_db_session_sites.py(new, 20 tests).How to Test
Real
SessionDBfiles under a tempHERMES_HOMEwith two profiles, no db mocks — a mocked handle hides this defect entirely (both dbs answer identically, and every failure here is specifically "the row is not in the db you asked"). Both dbs are seeded under the same session id wherever the write target would otherwise be ambiguous, plus a mock-shaped contract test that fails the branch handler if the launch handle is touched at all for a profile session.16 of the 20 fail on the parent commit; 4 are controls that pass on both. To see the failures, check out the parent and re-run the same file.
Manually, in the desktop's app-global remote mode: create a chat under a non-launch profile, then
/branch(previously "branch failed: FOREIGN KEY constraint failed"),/history(previously "No conversation history yet."),/status(previously no title, Created = now), rename it and reopen (the title previously vanished), and edit an earlier message then resume (the edited-away turns previously came back).Checklist
Code
tests/tui_gateway,tests/test_tui_gateway_server.py,tests/test_profile_isolation_runtime.py— 906 tests, green)Documentation & Housekeeping
docs/, docstrings) — N/A (behavior fix; the new/changed comments carry the rationale)cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/APaththroughout)