Skip to content

Fix/per profile remote ws routing - #85750

Closed
Tigrannnnnnn wants to merge 6 commits into
NousResearch:mainfrom
Tigrannnnnnn:fix/per-profile-remote-ws-routing
Closed

Tigrannnnnnn wants to merge 6 commits into
NousResearch:mainfrom
Tigrannnnnnn:fix/per-profile-remote-ws-routing

Conversation

@Tigrannnnnnn

@Tigrannnnnnn Tigrannnnnnn commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

PR: fix(desktop): per-profile remote overrides attach chat WS to the local primary

What

A Desktop profile configured with its own remote gateway override
(connection.json profiles[name] = { mode: "remote", url, token })
silently runs its chat on the local primary backend:

Root cause

sharedPrimaryRoute() (apps/desktop/src/store/gateway.ts) decides a profile
is "served by the shared primary" when the descriptor has a .profile field:

return Boolean(conn && typeof conn === 'object' && (conn as { profile?: string }).profile)

But two routes produce a .profile-tagged descriptor:

  1. Shared-primary (case 3, global remote): ensureBackend returns
    { ...connection, profile: route.descriptorProfile } — the intended target
    of the check.
  2. Per-profile remote override (case 2): spawnPoolBackend returns
    { ...remote, profile, ... } (electron/main.ts:8117) — the pool descriptor
    is tagged with the profile name too.

    The check cannot distinguish them, so a per-profile remote override is
    misclassified as shared-primary → ensureGatewayForProfile activates the
    PRIMARY (local) socket instead of dialing the profile's remote WS. The commit
    message claims "per-profile remote overrides are untouched (pinned by test)" —
    the test missed the spawnPoolBackend return path.

Fix

Tag only the true shared-primary descriptor with a dedicated marker and check
for that, not .profile:

  • electron/main.ts — primary route with descriptorProfile:
    { ...connection, profile: route.descriptorProfile, sharedPrimary: true }
  • apps/desktop/src/store/gateway.tssharedPrimaryRoute checks
    conn.sharedPrimary === true; pool descriptors never carry the marker.
    Covers both callers (openGatewayForProfile pre-warm and
    ensureGatewayForProfile) — the whole bug class, not just one path.

Reproduction

  1. Local primary profile; add a second profile with a remote gateway override.
  2. Select the remote profile; open/resume a session.
  3. Chat answers from the LOCAL host; remote sessions say "session not found",
    while the sidebar (REST) lists remote sessions.

Test

  • Unit: sharedPrimaryRoute — descriptor { profile: 'x' } → false;
    { profile: 'x', sharedPrimary: true } → true (covers both route shapes).
  • E2E guidance: per-profile remote override + local primary must dial the
    override's WS URL (assert the chat socket target, not just REST).

Related

Fixes #85777

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 14, 2026
@spfcraze

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary:
The new sharedPrimary === true predicate makes the existing gateway-shared-remote.test.ts case 1 fail: it still stubs a .profile-tagged descriptor without a sharedPrimary flag, which the changed sharedPrimaryRoute now routes to the pooled dial path instead of the primary.

Problems:

  • apps/desktop/src/store/gateway-shared-remote.test.ts (unchanged by this diff) case 1 stubs getConnection as { port: 4242, profile: 'venture', token: 't' } — no sharedPrimary flag — and asserts expect($gateway.get()).toBe(primary). Under sharedPrimary === true that descriptor returns false, so ensureGatewayForProfile('venture') dials a pooled socket (the stub's connect throws) and activates the secondary, failing the assertion.
  • The diff changes only electron/main.ts and src/store/gateway.ts (2 files, no test), yet the description's Test section describes a sharedPrimaryRoute unit test (descriptor { profile: 'x' } → false; { profile: 'x', sharedPrimary: true } → true) that is not in the diff.

Solution:
Update gateway-shared-remote.test.ts case 1's mock to carry sharedPrimary: true (the shape the changed ensureBackend now returns for the shared-primary route) and include the sharedPrimaryRoute unit test the description claims, so the changed predicate is pinned instead of breaking an existing assertion.

Evidence

no deterministic fact backs this claim — model belief, not executed or read evidence


Checked against 1815ad2 — the tip of fix/per-profile-remote-ws-routing when this was written — and 486f4ac, main at the same moment.

@jeremyrandria-debug

Copy link
Copy Markdown

Independent confirmation that this discriminator fix also resolves the local pooled profile case, not just per-profile remote overrides.

With local profiles (default + two named local profiles), spawnPoolBackend tags the local pooled descriptor with profile too, so the .profile check routes every named local profile through the default socket — the sidebar stays pinned to the default profile's projects/sessions and sessions snap back to default. The pooled backend itself is healthy (it serves its own projects.list correctly over WS); only the routing classification is wrong.

Verified locally with a shared-primary discriminator (tested as mode !== 'local', equivalent to the sharedPrimary marker here): after rebuilding, switching to a named local profile shows that profile's own projects and sessions. Suggest porting the regression test from #85778 (or keeping that PR's test when this merges) — it protects the local-pooled case, which this PR's body doesn't explicitly mention.

@Tigrannnnnnn

Copy link
Copy Markdown
Contributor Author

Test updated per triage; overlaps with #85778 — happy to close in its favor if maintainers prefer one patch.

@cmoiccool

Copy link
Copy Markdown
Contributor

Thanks for this fix. I independently reproduced the per-profile remote override case addressed here.

Configuration shape:

  • the primary Desktop profile uses a local backend
  • a second profile has its own remote URL override
  • Desktop is not using a global remote connection

Observed behavior:

  • REST and session-list requests reached the profile's remote backend
  • the persistent chat connection continued using the primary backend
  • new chat and tool activity consequently ran on the primary host

I validated the proposed discriminator against the focused Desktop tests:

  • main at c896c09c42 with the regression test: 1 failed, 12 passed
  • this PR at e74ea76700: 13 passed

Command:

cd apps/desktop
npm test -- --run \
  src/store/gateway-shared-remote.test.ts \
  src/store/profile.test.ts

The explicit sharedPrimary marker appears to be the correct root-cause fix: profile identifies connection ownership/scope, while sharedPrimary identifies whether the primary socket should actually be reused.

#85778 already adds the central HermesConnection.sharedPrimary typing and links the same classifier bug to #85777. To avoid another competing upstream PR, would you welcome a small PR against this PR's branch that strengthens the test to assert that a profile-owned remote descriptor passes its exact wsUrl to HermesGateway.connect()?

I prototyped that test on this PR's current head with an isolated test fixture using remote.invalid and a fake test token. The focused suite remains 13/13 passing. It would be a test-only addition and would not modify the production fix.

If #85750 remains the canonical implementation, it may also be useful to add Fixes #85777 to its description, or otherwise let maintainers decide whether #85750 or #85778 should carry the closing reference.

@Tigrannnnnnn

Tigrannnnnnn commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@cmoiccool Thanks for the independent validation — great to have the focused suite green at 13/13.

Yes, please open the test-strengthening PR against my branch — happy to merge it in (test-only is exactly right).

I'll add Fixes #85777 to the description now.

@infinitycrew39

Copy link
Copy Markdown
Contributor

Tracking this for #85745 — same root cause as the profile-tab session list regression on 0.20.1. Core fix looks correct; happy to follow up with a separate wiring refresh PR after merge if sidebar still needs an explicit re-fetch on profile switch.

test(desktop): pin profile-owned remote WebSocket target
@runninwithitmarketing

runninwithitmarketing commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Independent reproduction + end-to-end verification of this fix on the pure local-profiles case (no remote overrides, no global remote — the PR description frames the repro around per-profile remote overrides, but the same misclassification hits plain local pools too).

Repro (v0.20.1, macOS arm64, local mode, 8 local profiles):

  • Switching to any non-primary local profile showed the PRIMARY's project tree in every profile view. Pinned sessions stayed correct (different fetch path), which made it look like data corruption rather than routing.
  • The pooled serve for each profile spawned fine (--profile <name> serve --host 127.0.0.1 --port 0, LISTENING on its ephemeral port), but the renderer never dialed its WS — 0 ESTABLISHED connections.
  • Direct JSON-RPC projects.tree probe against each pooled backend returned that backend's own correct tree, proving the data and backends were healthy; only the socket routing was wrong.

Downstream data damage worth noting for severity: because RPCs landed on the wrong backend while the UI showed another profile, sessions opened "in" a profile were actually created in the primary's state.db. We found 8 sessions born in the wrong profile's database this way (4 client sessions in default that belonged to hm, 2 in stock that belonged to hm, 2 in video that belonged to stock) and had to migrate them between profile state.dbs manually. Affected users may report this as "my project folders are in the wrong profile / missing" — it's this bug plus resulting data misplacement, not a projects.db issue.

Verification of the fix: applied the same sharedPrimary: true marker (electron/main.ts ensureBackend) + conn.sharedPrimary === true check (src/store/gateway.ts) locally, including the pool descriptor carrying profile but no marker → pooled path dials. Rebuilt, confirmed every profile now renders its own tree; user-verified. Tests in gateway-shared-remote.test.ts (3/3) and typecheck pass.

The marker approach covers the whole class since both openGatewayForProfile and ensureGatewayForProfile funnel through sharedPrimaryRoute. Hope this helps get it merged — happy to test any follow-up.

@OutThisLife

Copy link
Copy Markdown
Contributor

Superseded by #86325, which consolidates the sharedPrimary fix from this PR and its siblings (#85750, #85778, #85932) into one change — credit to all three authors via Co-authored-by. Thanks @Tigrannnnnnn!

OutThisLife added a commit that referenced this pull request Aug 14, 2026
…0.20.1

sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.

Regression from #85665 (d16e236). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.

Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.

Supersedes #85750, #85778, #85932
Fixes #85777

Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
skappafrost pushed a commit to skappafrost/hermes-agent that referenced this pull request Aug 15, 2026
…0.20.1

sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.

Regression from NousResearch#85665 (977f744). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.

Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.

Supersedes NousResearch#85750, NousResearch#85778, NousResearch#85932
Fixes NousResearch#85777

Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
…0.20.1

sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.

Regression from NousResearch#85665 (8340823). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.

Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.

Supersedes NousResearch#85750, NousResearch#85778, NousResearch#85932
Fixes NousResearch#85777

Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…0.20.1

sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.

Regression from NousResearch#85665 (d16e236). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.

Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.

Supersedes NousResearch#85750, NousResearch#85778, NousResearch#85932
Fixes NousResearch#85777

Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
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 comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists 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.

fix(desktop): local profile switch reuses default socket after update

8 participants