Skip to content

fix(desktop/sessions): legacy NULL-owner session migration — single-match owner backfill + read-only stored-transcript resume (#94724) - #96110

Merged
teknium1 merged 2 commits into
mainfrom
fix/legacy-session-owner-backfill
Aug 27, 2026
Merged

teknium1 merged 2 commits into
mainfrom
fix/legacy-session-owner-backfill

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Updating no longer strands your existing chats. After the multi-gateway ownership campaign (#95407), any Desktop with two or more registered connections failed closed on every pre-campaign session row — profile_name = NULL, no migration — so years of history became unopenable ("Error B": SessionOwnerResolutionError on session.resume), even though every transcript sat fully intact in state.db. This PR ships the missing legacy-session migration plus a guaranteed recovery path, following the shape @ruangraung proposed in the field report.

Credit: @ruangraung for the precise field report on #94724 (1,120 of 1,122 rows NULL, both error shapes, code-level diagnosis of the session-owner-resolution.ts topology gate) and the repro offer — this PR implements the reporter's own proposal.

What changed

1. Single-match owner backfill (durable, server-side, one shot)

  • POST /api/sessions/owner-backfill + SessionDB.backfill_null_session_profiles(): stamps a store's own serving-profile identity onto its legacy profile_name IS NULL / '' rows. Single match by construction — each profile's state.db belongs to exactly one profile — so it is a migration, never a guess.
  • Idempotent, one-shot-per-row, never overwrites a non-NULL owner, returns/logs the stamped count.
  • Desktop triggers it from the fix(desktop): session ownership is durable across multi-gateway — rows keep their exact owner (wave-2 phase A, tracker #94724) #95407 stamp path (stampActiveConnectionOwner → maybeBackfillLegacySessionOwners), once per serving scope per renderer. resolveLegacyOwnerBackfillScope picks the target store fail-closed: serving registered connection → that backend; primary pool → the primary's own store; unknown source with >1 registered candidate → no stamp (multi-candidate: never guess).

2. Read-only stored-transcript resume (the no-owner recovery path)

  • When session.resume fails closed with SessionOwnerResolutionError, the session now opens as a read-only stored transcript: an id-only REST read (ambient store first, then read-only probes of each registered backend — a GET of a backend's own state.db mints nothing and routes no live session) paints the intact history instead of the dead-end error.
  • Sends into a read-only transcript are refused with an explanatory notice (main composer + tile submit); a later successful live resume (e.g. after the backfill stamps the row) clears the latch automatically.
  • The session-tile delegate now runs the same fail-closed owner gate as the window's RPC dispatcher before dispatching session.resume, so tiles recover read-only instead of misrouting to the ambient socket.

The fail-closed ladder itself is untouched — ambiguous owners still never ride the ambient gateway. Users just stop paying for that correctness with their history.

Live repro (two-gateway CDP rig, Electron headless :9555, fresh HERMES_HOME)

Live repro: Desktop CDP rig, persisted pre-campaign tab (no ownerRoute) + registry topology (local + URL remote Gateway B on :9401), 44 legacy profile_name=NULL rows seeded in B's state.db, target row outside the sidebar page — before (main @ 29033a3fd5): tile dead-ends in Couldn't open this session — Session owner could not be resolved for "fl-legacy-target" (session.resume)… (the reporter's exact Error B screenshot shape); after (this branch): the same boot paints the stored transcript with the "Opened read-only" notice, a send attempt is refused with "sending is disabled", and switching to Gateway B fires the one-shot backfill — renderer log stamped 54 legacy session row(s) with profile "default" on fl-gw-b, sqlite ground truth 54 NULL → 0 NULL, target row ('fl-legacy-target', 'default'), immediate re-run returns stamped: 0 (idempotent).

Tests (red pre-fix, sabotage-proven)

  • tests/hermes_cli/test_session_owner_backfill.py — stamps only NULL/blank rows, non-NULL (researcher) untouched, idempotent second run = 0, stamped rows circulate owned on the list endpoint. Red pre-fix (route absent: 3/3 fail on main). Sabotage: widening the SQL WHERE to overwrite non-NULL rows is caught by the never-overwrite assertion.
  • apps/desktop/src/lib/legacy-session-owner-backfill.test.ts — single-match scope resolution incl. both fail-closed rungs. Sabotage: changing multi-candidate === 1 to >= 1 (guessing) is caught.
  • apps/desktop/src/store/read-only-transcript.test.ts — drives the REAL assertSessionOwnerResolved gate on a 2-connection topology: read-only outcome with zero gateway dispatch, live path clears the latch, non-owner errors rethrow, stored-read failure rethrows the ORIGINAL owner error. Sabotage: removing the recovery catch is caught.
  • Neighbors green: session-owner-resolution, session-rpc-dispatcher, profile-routing, session-states-runtime-map (63 tests), prompt-actions suite (228 tests), tsc ×1 project, eslint, ruff.

Refs #94724

Infographic

legacy-sessions-rescued

…profile rows (#94724)

POST /api/sessions/owner-backfill stamps a store's own serving-profile
identity onto its pre-#95407 'profile_name = NULL' session rows. Single
match by construction (each profile's state.db belongs to exactly one
profile), idempotent, one-shot-per-row, never overwrites a non-NULL
owner, and reports the stamped count for logging.

Refs #94724
…fill trigger (#94724)

The fail-closed owner ladder (#95407) is correct for new sessions, but
legacy unowned rows on registry-topology installs dead-ended in
SessionOwnerResolutionError (reporter's Error B) with their transcripts
fully intact in state.db.

- resolveLegacyOwnerBackfillScope: pick the single-match store for the
  server-side owner backfill at enumeration time (serving registered
  connection / primary pool); fail closed on multi-candidate topologies.
- maybeBackfillLegacySessionOwners: one-shot per scope per renderer,
  fire-and-forget from the #95407 stamp path, logs the stamped count.
- Read-only stored-transcript resume: when session.resume fails closed,
  fetch the transcript over id-only REST (ambient first, then registered
  backends, read-only probes only) and open the session as a read-only
  transcript instead of dead-ending; sends are refused with a notice and
  a later successful live resume clears the latch. Wired into the main
  pane resume recovery and the session-tile delegate (which now runs the
  same fail-closed owner gate as the RPC dispatcher).

Refs #94724
@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on dabb369 — feat(desktop): read-only stored-transcript resume + legacy o

⚠️ Warnings

CI timings · View report · View job

Wall time 9m3s vs 3m33s (+154.9%). 6 job(s) slower, 6 faster, 1 unchanged.

  • Python tests / Run tests: +36.0s
  • OS-specific tests / Windows-only tests: +18.0s
  • JS & TS checks / JS & TS checks: -8.0s
  • Check contributors / check-attribution: -5.0s
  • OSV scan / Emit review status: -4.0s

OSV vulnerability scan · View job

6 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) comp/cli CLI entry point, hermes_cli/, setup wizard area/sessions Session lifecycle, resume, persistence, history area/profiles Multi-profile isolation, HERMES_HOME scoping P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 27, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head dabb369fd8c6b6b6ebfaa67bbfb95eeddb19b56b against live main@1a66134404b891170e953f51662e6429f0b7b5a9. This is a direct two-commit child of current main: 19 files, +936/-12. I read the full diff, the current owner-resolution contract, the server profile/session-store routing, the new migration and read-only recovery tests, #94724, merged #95407, and the adjacent #95961/#96092 session-preservation work. There were no prior human reviews on this head when I started.

There is a lot worth preserving here. The server mutation is deliberately one-way (NULL/blank only), idempotent, and cannot overwrite an existing non-NULL owner. The live-resume path clears its read-only latch. The tile and main-composer write paths both refuse a synthetic read-only session instead of trying to turn a stored transcript into an ambient live runtime. And the work correctly treats the #95407 fail-closed owner ladder as the invariant to preserve rather than weakening it just to make old chats open again. ruangraung deserves the field-report credit already recorded in the body; #95407's salvaged Zeus-Deus, weismanfamily, and joe-rodgers ownership lineage also remains load-bearing here.

I found two P1 authority/identity holes in the recovery transaction, plus one hard repository gate.

P1 — read-only probing can display the wrong backend's same-ID transcript

apps/desktop/src/api/sessions.ts::fetchStoredTranscriptAcrossBackends() does this deliberately:

  1. read the ambient/primary store by bare session id;
  2. then iterate registered backends;
  3. return the first backend for which getLatestSessionMessages(id, { connectionId, profile: 'default' }) succeeds.

That is still an ownership decision, even though the result is read-only. Current main's apps/desktop/src/store/session-owner-resolution.ts explicitly documents the failure this creates: a bare session_id only means anything on the backend that owns it, and an ambient/guessed fallback can return an answer from a backend that merely happens to know a same-named session. #95407 made that ambiguity fail closed for live RPCs; this PR reintroduces it at the transcript projection boundary.

Concrete bad shape: registered gateways A and B both contain stored row legacy-1 (session ids are not a cross-install authority namespace) with different transcripts. Owner resolution correctly says “unknown”. fetchStoredTranscriptAcrossBackends('legacy-1') paints whichever matching backend is visited first. No mutation occurs, but the UI has now associated the requested chat with another backend's history. That is a privacy/data-association failure, not a safe read fallback.

Required repair: recovery may consume an exact owner proof or a unique-match proof, not “first success”. Probe identity/metadata in a way that can establish exactly one (connection, profile, stored_session_id) candidate, then fetch from that candidate. If two stores match, or an unreachable candidate makes uniqueness unknowable, preserve the SessionOwnerResolutionError / explicit ambiguity state rather than choosing. Do not weaken the existing live-RPC gate.

Please add a deterministic regression with the same stored session id present on two registered backends with different message content: recovery must refuse ambiguity and must not paint either transcript. A one-and-only-one match should open read-only. Also cover non-default profiles rather than hard-coding profile: 'default' as the probe identity.

P1 — the durable backfill drops the profile/store dimension that selected the rows

The migration's claim is “the backend that serves an enumeration owns every row it serves.” That is only true when the exact profile store that served the page is preserved through the mutation.

On this head:

  • stampActiveConnectionOwner(sessions) calls maybeBackfillLegacySessionOwners() with no ProfileScope at all.
  • resolveLegacyOwnerBackfillScope() returns profile: null for every successful case.
  • the POST therefore normally carries no profile.
  • POST /api/sessions/owner-backfill chooses its physical DB with _open_session_db_for_profile(body.profile, read_only=False) and stamps _cron_default_profile() when body.profile is absent.

But the existing session REST surface is explicitly profile-scoped: a named ?profile=researcher opens that profile's on-disk state.db, and the list endpoint stamps returned rows with that serving profile. So a page can be served from researcher while this follow-up POST opens the process/default store and backfills that store instead. The researcher legacy rows that triggered the migration remain NULL. The aggregated /api/profiles/sessions surfaces make the same problem sharper: one page can contain rows from multiple profile stores, but the current fire-and-forget hook has no per-row/per-store authority left by the time it mutates.

This is the same qualified-identity rule as #95407: connection alone is not the owner coordinate once a connection serves multiple profile stores.

Required repair: carry the exact serving ProfileScope into the backfill request. For a single-profile page, the POST must target the same (connectionId, profile) that produced the rows. For aggregated multi-profile pages, either perform the migration server-side while enumerating each known store or group exact stores and issue one backfill per proven store; one unscoped default-store POST cannot represent that page.

Please add a vertical regression with NULL rows in both default and researcher: enumerate only researcher, run the real backfill trigger, and prove only the researcher store is stamped with researcher. Then cover an aggregated page and prove every touched store receives only its own serving profile, exactly once. The existing “never overwrite non-NULL” regression should stay.

Hard architecture gate — hermes_state.py is being regrown

This PR adds a new SessionDB.backfill_null_session_profiles() method around line 9,385 of hermes_state.py. The repository's active decomposition doctrine has hermes_state.py in the ~9.5K-line godfile set; the hard rule is shard-before-growth, not “keep adding until its campaign starts”. This migration is a good candidate for a small session-ownership/migration seam, but it should not restore new behavior into the monolith. Preserve the exact SQL semantics and tests while routing the behavior through a sub-2K owner.

This also matters for live coordination: #96092 is independently changing the hermes_state.py session-retention surface to keep archived transcript rows from being hard-deleted, while #95961 owns the complementary reload/end-state side. They are not duplicates of #96110: #95961 keeps populated sessions open through gateway bounce, #96092 protects the durable transcript from the empty-session sweep, and #96110 restores ownership/reachability for legacy rows. They form one preservation/recovery chain and should be semantically re-read together anywhere they share state-store assumptions.

Exact-head evidence

The exact head is not green yet. Docker 33043453027 and Nix 33043453177 are green; Python, macOS, Windows, attribution, common-ancestor, supply-chain and ruff/footgun lanes inside CI are green. CI 33043453559 is red because JS & TS checks / JS & TS checks job 98422031635 failed at Run all workspace checks, which also makes All required checks pass red. The available job metadata does not expose the failing command/output, so I am not assigning a cause I cannot prove.

Graph / landing order

  • #94724 remains the owning multi-gateway tracker and ruangraung's field evidence is the direct provenance for this migration.
  • Merged #95407 is the authority root this work must refine, not bypass. Preserve its Zeus-Deus / weismanfamily / joe-rodgers lineage and its connection+profile fail-closed semantics.
  • #95961 is complementary reload-lifecycle preservation.
  • #96092 is complementary hard-delete prevention and currently shares the hermes_state.py state-owner surface; whichever moves first needs a fresh semantic read of the session-row invariants.

The important design choice here is right: old history should remain readable without turning “unknown owner” into permission to mutate. The remaining work is to make read authority as qualified as write authority, and to keep the exact profile/store coordinate all the way from enumeration to durable backfill. Once those two proof gaps are closed, the recovery path becomes a strong companion to #95407 rather than an exception to it. 🚀

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final exact-object read-back after the review above: the code head is still unchanged at dabb369fd8c6b6b6ebfaa67bbfb95eeddb19b56b on main@1a66134404b891170e953f51662e6429f0b7b5a9, but GitHub now reports the PR as non-mergeable. Exact-head CI 33043453559 is also terminal red in JS & TS checks / JS & TS checks (98422031635), while Docker 33043453027 and Nix 33043453177 are green. This is a topology/evidence update only; it does not change either P1 finding or the godfile-regrowth gate in review 5037631225.

@teknium1
teknium1 merged commit 9faa685 into main Aug 27, 2026
67 of 69 checks passed
@teknium1
teknium1 deleted the fix/legacy-session-owner-backfill branch August 27, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P1 High — major feature broken, no workaround sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants