Skip to content

fix(gateway): read and write a live session's rows in its own profile db - #333

Merged
OmarB97 merged 1 commit into
mainfrom
fix/gateway-profile-db-sites-fork-20260802
Aug 2, 2026
Merged

fix(gateway): read and write a live session's rows in its own profile db#333
OmarB97 merged 1 commit into
mainfrom
fix/gateway-profile-db-sites-fork-20260802

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

_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>" (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 in tui_gateway/server.py that runs with a live session dict 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 SessionDB files under a temp home with two profiles — the failing line is named, not inferred.

session.branch — hard failure, /branch unusable 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_session raised and the handler returned branch 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 no profile_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_bound binding around _resolve_model() — the same mis-binding #325 fixed for _ensure_session_db_row, made permanent here by the row's COALESCE upsert).

prompt.submit history truncation — silent data loss on a message edit. db.replace_messages(session_key, truncated) raised FOREIGN KEY constraint failed on the launch db. The except swallowed it, which also skipped deactivate_turn_outcomes_from_ordinal in the same try, 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 /undo rewind in #323.

Notification ownership — dropped delegation completions. Both _session_owns_notification_event and _notification_event_belongs_elsewhere map an event's session_key to its continuation tip via resolve_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 logged Dropping unowned async_delegation notification and threw the result away.

/history showed nothing. _format_live_history_output assigns history = db.get_messages_as_conversation(...) unconditionally, so an empty launch-db read replaced the live in-memory window and /history answered "No conversation history yet." mid-conversation. The session.history RPC has used _session_db for 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 launch UPDATE matched 0 rows and fell through to the _ensure_session_db_row + _session_db recovery 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_title fed that same "" into every session.info; the post-turn pending_title apply got False from a 0-row UPDATE, so a title given at session.create was retried every turn and never persisted; and maybe_auto_title wrote to the launch db, leaving profile sessions permanently untitled in the sidebar.

/status showed invented timestamps. db.get_session(key) returned no row, so there was no Title: line and Created / Last Activity fell back to datetime.now() on every call.

Background/preview subagents wrote to the wrong profile. Every other field in _background_agent_kwargs is inherited from the parent agent; session_db was not. Both callers pass session["agent"], so a profile session's prompt.background transcript landed in the launcher's state.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_title writes from a daemon thread, _background_agent_kwargs feeds an agent that runs on its own thread, and the notification helpers run in a poll loop. A _session_db handle would be closed on block exit in the first two, and in the third a fresh read-write SessionDB per 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, no profile param. 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) in tools/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_db itself — 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

  • 🐛 Bug fix (non-breaking change that fixes an issue)

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, stamp profile_home on the child session; release the handle on the error paths.
  • prompt.submitreplace_messages / deactivate_turn_outcomes_from_ordinal via _session_db(session).
  • New _resolve_session_lineage_key(session, evt_key) — one compression-chain resolver shared by _notification_event_belongs_elsewhere and _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_submitpending_title via _session_db(session); maybe_auto_title gets the agent's own handle.
  • _background_agent_kwargssession_db inherited from the parent agent.

tests/tui_gateway/test_profile_db_session_sites.py (new, 20 tests).

How to Test

pytest tests/tui_gateway/test_profile_db_session_sites.py -q

Real SessionDB files under a temp HERMES_HOME with 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

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run the affected suites and they pass (tests/tui_gateway, tests/test_tui_gateway_server.py, tests/test_profile_isolation_runtime.py — 906 tests, green)
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin 25.6.0)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A (behavior fix; the new/changed comments carry the rationale)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact — N/A (no platform-specific paths; Path throughout)
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

`_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>
@OmarB97
OmarB97 merged commit 7490b2c into main Aug 2, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant