Profile deletion and rename leave stale state
NousResearch/hermes-agent · cluster 45474 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #49435. Check it covers your case.
The short answer
- Known?
- Yes. 11 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #49435. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #88732. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of bug reports and fixes addressing profile deletion and rename operations that leave behind stale directories, backend processes, or sockets, causing deleted or renamed profiles to reappear or respawn. The fixes target CLI and desktop layers to stop backends before deletion and prevent respawn loops.
Open issues0
Open PRs0
Fixes on record
-
#49435 Prevent deleted profiles from being recreated by stale dashboards
files:
hermes_cli/config.py,tests/hermes_cli/test_config.pyShow the diff
-
hermes_cli/config.py@@ -847,6 +847,15 @@ def ensure_hermes_home(): any files created (e.g. SOUL.md) are group-writable (0660). """ home = get_hermes_home() + # Named profiles must be created explicitly (e.g. ``hermes profile create``). + # If a stale process keeps running after the profile was renamed/deleted, + # silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton + # and make the deleted profile reappear in Desktop/profile lists. + if home.parent.name == "profiles" and not home.exists(): + raise FileNotFoundError( + f"Named profile home does not exist: {home}. " + "Create the profile explicitly before using it." + ) if is_managed(): old_umask = os.umask(0o007) try: -
tests/hermes_cli/test_config.py@@ -89,6 +89,22 @@ def test_preserves_legacy_template_with_user_persona(self, tmp_path): ensure_hermes_home() assert soul_path.read_text(encoding="utf-8") == mixed + def test_existing_named_profile_still_bootstraps_subdirs(self, tmp_path): + profile_home = tmp_path / ".hermes" / "profiles" / "coder" + profile_home.mkdir(parents=True) + with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}): + ensure_hermes_home() + assert (profile_home / "cron").is_dir() + assert (profile_home / "sessions").is_dir() + assert (profile_home / "memories").is_dir() + + def test_missing_named_profile_is_not_recreated(self, tmp_path): + profile_home = tmp_path / ".hermes" / "profiles" / "coder" + with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}): + with pytest.raises(FileNotFoundError, match="Named profile home does not exist"): + ensure_hermes_home() + assert not profile_home.exists() + class TestLoadConfigDefaults: def test_returns_defaults_when_no_file(self, tmp_path):
-
-
#57329 fix(desktop): converge profile deletion — stop respawn + refresh rail (supersedes #52301, #49335)
files:
apps/desktop/electron/main.cjs,apps/desktop/electron/profile-delete-respawn.test.cjs,apps/desktop/src/app/profiles/index.tsx,apps/desktop/src/store/profile.test.ts,apps/desktop/src/store/profile.tsShow the diff
-
apps/desktop/electron/main.cjs@@ -5419,19 +5419,24 @@ function profileNameFromDeleteRequest(request) { return name.toLowerCase() } +// Returns the profile name whose backend was torn down, or null when the +// request is not a profile-delete. The caller uses this to skip ensureBackend +// for the just-torn-down profile — otherwise ensureBackend respawns a pool +// backend whose ensure_hermes_home() recreates the deleted profile directory. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { - return + return null } if (profile === primaryProfileKey()) { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() - return + return profile } await teardownPoolBackendAndWait(profile) + return profile } async function startHermes() { @@ -6465,10 +6470,15 @@ ipcMain.handle('hermes:api', async (_event, request) => { return rerouted } - await prepareProfileDeleteRequest(request) + const tornDownProfile = await prepareProfileDeleteRequest(request) const profile = request?.profile - const connection = await ensureBackend(profile) + // After tearing down a backend for profile deletion, route to the primary + // backend instead of spawning a fresh pool backend. A freshly spawned + // backend calls ensure_hermes_home() which recreates the profile directory, + // defeating the deletion and leaving a zombie process. + const routeProfile = tornDownProfile ? null : profile + const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), -
apps/desktop/electron/profile-delete-respawn.test.cjs@@ -0,0 +1,66 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const ELECTRON_DIR = __dirname + +function readElectronFile(name) { + return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') +} + +// --------------------------------------------------------------------------- +// prepareProfileDeleteRequest must return the torn-down profile name so the +// caller can skip ensureBackend for that profile (issue #52279). +// --------------------------------------------------------------------------- + +test('prepareProfileDeleteRequest returns the torn-down profile name', () => { + const source = readElectronFile('main.cjs') + + // Locate the function definition and its closing brace. + const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') + assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found') + + // The function must contain "return profile" (pool and primary paths). + const fnBody = source.slice(fnStart, fnStart + 800) + const returnProfileCount = (fnBody.match(/return profile/g) || []).length + assert.ok( + returnProfileCount >= 2, + `expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}` + ) + + // The early-exit guard must return null (not void/undefined). + assert.match( + fnBody, + /return null/, + 'early-exit guard should return null, not undefined' + ) +}) + +test('hermes:api handler routes profile-delete requests to the primary backend', () => { + const source = readElectronFile('main.cjs') + + // The handler must capture prepareProfileDeleteRequest's return value. + assert.match( + source, + /const tornDownProfile = await prepareProfileDeleteRequest\(request\)/, + 'handler should capture the return value of prepareProfileDeleteRequest' + ) + + // The handler must use the return value to skip ensureBackend for the + // torn-down profile, routing to the primary (null) instead. + assert.match( + source, + /const routeProfile = tornDownProfile \? null : profile/, + 'handler should route to primary backend when a profile was just torn down' + ) + + // ensureBackend must be called with the conditional route profile. + assert.match( + source, + /const connection = await ensureBackend\(routeProfile\)/, + 'handler should pass routeProfile (not raw profile) to ensureBackend' + ) +}) -
apps/desktop/src/app/profiles/index.tsx@@ -19,7 +19,6 @@ import { Textarea } from '@/components/ui/textarea' import { createProfile, deleteProfile, - getProfiles, getProfileSoul, type ProfileInfo, renameProfile, @@ -31,7 +30,7 @@ import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { slug } from '@/lib/sanitize' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' -import { $profileColors } from '@/store/profile' +import { $profileColors, refreshProfiles } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { @@ -72,7 +71,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { const refresh = useCallback(async () => { try { - const { profiles: list } = await getProfiles() + const list = await refreshProfiles() setProfiles(list) setSelectedName(current => { if (current && list.some(p => p.name === current)) { -
apps/desktop/src/store/profile.test.ts@@ -2,6 +2,7 @@ import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesConnection } from '@/global' +import type { ProfileInfo } from '@/types/hermes' // Keep profile.ts's side-effecting imports inert: the gateway socket layer and // the REST query client must not run for real in a unit test. @@ -17,9 +18,20 @@ vi.mock('@/hermes', () => ({ vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } })) vi.mock('@/store/starmap', () => ({ resetStarmapGraph })) -const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile') +const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile') const { $connection } = await import('./session') const { queryClient } = await import('@/lib/query-client') +const { getProfiles } = await import('@/hermes') + +const profile = (name: string, isDefault = false): ProfileInfo => ({ + has_env: false, + is_default: isDefault, + model: null, + name, + path: `/tmp/hermes/${name}`, + provider: null, + skill_count: 0 +}) const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection => ({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection @@ -35,6 +47,7 @@ beforeEach(() => { $gateway.set({ id: 'live-socket' }) $activeGatewayProfile.set('default') $connection.set(localConn()) + $profiles.set([]) vi.stubGlobal('window', { hermesDesktop: { getConnection } }) vi.mocked(queryClient.invalidateQueries).mockClear() resetStarmapGraph.mockClear() @@ -101,3 +114,23 @@ describe('profile-scoped cache invalidation', () => { expect(resetStarmapGraph).toHaveBeenCalledTimes(1) }) }) + +describe('refreshProfiles shared rail list (#49289)', () => { + it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => { + $profiles.set([profile('default', true), profile('test1')]) + vi.mocked(getProfiles).mockResolvedValueOnce({ profiles: [profile('default', true)] }) + + await refreshProfiles() + + expect($profiles.get().map(profile => profile.name)).toEqual(['default']) + }) + + it('leaves the shared $profiles cache intact when the refresh fails', async () => { + $profiles.set([profile('default', true), profile('test1')]) + vi.mocked(getProfiles).mockRejectedValueOnce(new Error('backend unavailable')) + + await expect(refreshProfiles()).rejects.toThrow('backend unavailable') + + expect($profiles.get().map(profile => profile.name)).toEqual(['default', 'test1']) + }) +}) -
apps/desktop/src/store/profile.ts@@ -38,6 +38,13 @@ export function setActiveProfile(name: string): void { $activeProfile.set(name || 'default') } +export async function refreshProfiles(): Promise<ProfileInfo[]> { + const { profiles } = await getProfiles() + $profiles.set(profiles) + + return profiles +} + // ── Rail order ───────────────────────────────────────────────────────────── // User-defined order for the named (non-default) profile squares in the rail. // Names absent from the list fall back to alphabetical, appended at the tail — @@ -111,8 +118,7 @@ export async function refreshActiveProfile(): Promise<void> { } try { - const { profiles } = await getProfiles() - $profiles.set(profiles) + await refreshProfiles() } catch { // Leave the cached list in place. }
-
-
#88228 fix(desktop): kill interpreter-exec'd profile backends on delete + refresh profile rail on focus
files:
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx,apps/desktop/src/app/chat/sidebar/use-profile-rail-refresh-on-active.test.ts,apps/desktop/src/app/chat/sidebar/use-profile-rail-refresh-on-active.ts,hermes_cli/profiles.py,tests/hermes_cli/test_profiles.py -
#88732 fix(desktop): re-home backend and retire sockets on profile rename
files:
apps/desktop/electron/main.ts,apps/desktop/electron/profile-delete-routing.ts,apps/desktop/electron/profile-rename-routing.test.ts,apps/desktop/electron/profile-rename-routing.ts,apps/desktop/src/app/profiles/rename-profile-dialog.test.tsx,apps/desktop/src/app/profiles/rename-profile-dialog.tsx
3 PRs closed without merging
From the close comments: merged via another change 2 · PR volume cleanup 1
-
#52301 fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop (#52279) · merged via another change →
#57329,#49435,#49335,#47368,#52279 - #59554 fix(cli): ignore stale missing profile homes at startup · PR volume cleanup
-
#72152 fix(desktop): work profile deletion silently reverted after app restart · merged via another change →
1ed94d2,#88228
Closed issues · 4
- Closed#45474Bug: `hermes profile rename` leaves stale profile directory that GUI keeps reviving
- Closed#47368[Bug]: Desktop "Delete profile" silently fails and the profile keeps reappearing
- Closed#50175[Bug]: Deleting a profile fails when dashboard/gateway child processes are alive; profile reappears after every restart until full reboot
- Closed#52279Deleted profile respawns indefinitely, accumulating zombie backend processes
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.15: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab