fix(desktop): repair remote profile routing, sessions, and pool lifecycle - #72835
Merged
Conversation
Keep non-primary profiles that inherit the app-global remote on the primary connection descriptor instead of creating processless pool entries that the idle reaper repeatedly removes. Preserve per-profile remote overrides and local pooled backends, and cover the routing policy with behavioral tests. Co-authored-by: Rodrigo Fernandez <rodrigo@nxtlevelsaas.com>
A pooled backend entry pointing at a remote host has no child process, so the 'exit' handler that clears a dead local backend never fires. The renderer's 60s keepalive touch also spares it from the idle reaper. Nothing was left to retire the descriptor, so once the host went away the pool kept serving it and every profile bound to that host stayed broken until restart. Pooled remote descriptors now share the primary's liveness policy: probed on the same revalidate tick, keyed per base URL, and dropped only after the same consecutive-failure limit, so the next ensureBackend() rebuilds. Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
Electron pre-installs its own uncaughtException listener and only warns on unhandled rejections, so a main-process fault usually leaves the app running with the reason on stderr — which nothing captures when the app is launched from Finder or the Start menu. The fault never reaches desktop.log, so it is absent from `hermes debug share` and the user can only describe symptoms. Record both to desktop.log and flush synchronously, since a fault that does prove fatal leaves no chance for the batched async flush. Five loadURL calls were also unhandled, each able to leave a blank window with no explanation anywhere the user can send us; they now name the surface that failed. Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
Three helpers each re-derived part of the same decision: which backend serves profile P, and does its REST path need a `?profile=` scope. profileUsesPrimaryBackend answered the first half, pathWithGlobalRemoteProfile answered the second, and ensureBackend re-checked globalRemoteActive() around both. Splitting one table across three predicates is how the global-remote case ended up registering reapable pool entries for a backend it never owned. resolveProfileBackendRoute() states the four routes in one place and returns the backend, the descriptor scope, and whether the path needs a query parameter. The call sites read the answer instead of recomputing it. One behavior change falls out: `hermes:api` now passes the primary profile through, so the primary no longer sends itself a redundant `?profile=<self>` on a global remote that already serves it.
Co-authored-by: Rodrigo Fernandez <rod-nxtlevel@users.noreply.github.com> Co-authored-by: sealca <sealca@users.noreply.github.com> Co-authored-by: Vitor Cepeda Lopes <TheAngryPit@users.noreply.github.com> Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> Co-authored-by: nrmjeremy <nrmjeremy@users.noreply.github.com>
This was referenced Jul 27, 2026
Contributor
૮ >ﻌ< ა ci reviewran on 5409e81 ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 visual diff. inline evidence upload failed. Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso) |
3 tasks
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…mote-routing fix(desktop): repair remote profile routing, sessions, and pool lifecycle
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…mote-routing fix(desktop): repair remote profile routing, sessions, and pool lifecycle
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?
Consolidates the Desktop remote/profile routing cluster. #58108 reported six defects in one bundle; #68250 landed two of them and the rest have been sitting in separate PRs that each touch the same two files. Landing them one at a time would leave three overlapping predicates behind, so they come in together with the routing decision unified.
Sessions vanish from the sidebar on an OAuth gateway.
fetchProfilesSessionSliceandmergeRemoteProfileSessionscalledfetchJson(url, primary.token, …)directly. An OAuth primary has no static token, so the call 401s and the.catch()immediately below turns that into an empty list. The rows are still in the backend and still openable by id — they just stop appearing. Both paths now go throughfetchJsonForProfile, which already picks native-bearer vs cookie-partition.Profiles on an app-global remote churned through the backend pool. One remote backend serves every profile via
?profile=, butensureBackend()still registered non-primary profiles inbackendPool. Those entries have no process, so the idle reaper collected them and the next call re-probed the same remote, forever. They now share the primary connection with their scope carried on the descriptor.Pooled remote backends were never retired when their host died. A pooled entry backed by a remote has no child process, so the
'exit'handler that clears a dead local backend never fires, and the renderer's keepalive touch keeps the reaper off it. Revalidation only probed the primary, so a dead descriptor was served until restart. Pooled remotes now share the primary's liveness policy, keyed per base URL.Main-process faults left no trace. Electron pre-installs its own
uncaughtExceptionhandler and only warns on unhandled rejections, so the reason goes to stderr — discarded when the app launches from Finder or the Start menu. It never reacheddesktop.log, so it was absent fromhermes debug share. Both are now recorded and flushed synchronously, along with fiveloadURLcalls that could leave a blank window with no explanation.One routing table instead of three predicates.
profileUsesPrimaryBackendanswered "which backend",pathWithGlobalRemoteProfileanswered "does the path need a scope", andensureBackendre-checkedglobalRemoteActive()around both. Splitting one decision across three helpers is how the global-remote case ended up registering pool entries for a backend it never owned.resolveProfileBackendRoute()states the four routes once and returns backend, descriptor scope, and path scope together.Related Issue
Closes #41529.
Supersedes #58108, #67578, #72740, #72808.
Not included: #61033, which redirects local profile REST to the primary. Its symptom is real, but the premise doesn't hold — 121 of 224
/apiroutes take noprofileparameter, includingPOST /api/memory/reset,POST /api/curator/run, and the webhook routes, and/api/config,/api/model, and/api/skillsare only partially scoped. Today those calls reach a pool backend spawned with--profile, soHERMES_HOMEscopes them; redirecting them to the primary would silently act on the wrong profile. That fix needs an exact-path gate, and it now has a single predicate to extend rather than a third to add. Detail in #61033 (comment).Type of Change
Changes Made
apps/desktop/electron/profile-session-routing.ts(new) —fetchPrimaryProfileSessions(), shared by the legacy merge path and the batched sidebar path, preserving the empty-list fallback for a genuinely dead remote.apps/desktop/electron/connection-config.ts—resolveProfileBackendRoute()replacesprofileUsesPrimaryBackendand drivespathWithGlobalRemoteProfile.apps/desktop/electron/remote-liveness.ts—revalidatePooledRemoteBackends(), reusing the existingRemoteLivenessTrackerrather than adding a second policy.apps/desktop/electron/crash-forensics.ts(new) —describeCrashReason()andinstallCrashForensics(), dependency-injected so the handlers are tested for real.apps/desktop/electron/main.ts— read the route instead of recomputing it, recordentry.remoteBaseUrlfor pooled remotes, probe the pool on the revalidate tick, install the fault handlers, and route the unhandledloadURLcalls through a labelled helper.One behavior change beyond the fixes:
hermes:apinow passes the primary profile through, so the primary stops sending itself a redundant?profile=<self>on a global remote that already serves it.How to Test
apps/desktop:npx vitest run --project electronapps/desktop:npm run typecheckdesktop.logfills withReaping idle profile backendevery 600s. After: sessions list, and non-primary profiles stay on the shared connection.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — N/A, Desktop-onlyDocumentation & 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/ACredit
Every commit is cherry-picked so authorship survives in git history:
main.cjsimplementation of the pool-churn, pooled-revalidation, and crash-forensics fixes.fetchJsonForProfilefix for the session aggregate.Screenshots / Logs
Local validation on macOS, branched from
e643f2e91:npx vitest run --project electron: 813 passed, 2 skippednpm run typecheck: passed