Skip to content

fix(desktop): deleted profile stays deleted (closes #95188) - #95239

Closed
Finn763 wants to merge 1 commit into
NousResearch:mainfrom
Finn763:fix/95188-deleted-profile-resurrect
Closed

Finn763 wants to merge 1 commit into
NousResearch:mainfrom
Finn763:fix/95188-deleted-profile-resurrect

Conversation

@Finn763

@Finn763 Finn763 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Hermes Desktop on Windows resurrects a deleted named (bot) profile within
60–90 seconds of hermes profile delete <name> reporting success. Three
cooperating paths cooperate to undo the delete; this PR closes all three so
a deleted profile stays deleted until the user explicitly recreates it.

Path A — desktop cron multiplex ticker (hermes_cli / cron)

_start_desktop_cron_ticker calls profiles_to_serve() once at backend
startup and passes a frozen list of profile homes to
InProcessCronScheduler._start_multiplex. Every 60 s tick the
_atomic_write_epoch inside record_ticker_heartbeat() does
mkdir(parents=True, exist_ok=True) on profiles/<name>/cron/, recreating
the deleted profile's directory shell. Verified: after rm -rf of the
resurrected shell, the directory returned exactly one tick later with only
cron files (ticker_heartbeat, ticker_last_success, .jobs.lock,
.tick.lock).

Path B — stale hermes.desktop.lastProfileByConnection (renderer)

apps/desktop/src/store/connections.ts persists a per-source
last-profile map in localStorage. selectConnection() reads it on
boot restore and calls ensureGatewayAgent(<connection>, <deleted profile>) with no existence check. The delete dialog only reset this key
in the if (wasActive) branch — deleting from another workspace left the
key pointing at the dead profile forever. On the next desktop restart,
--profile <deleted> serve is spawned, whose ensure_hermes_home()
rebuilds the entire profile tree (config.yaml, state.db,
models_dev_cache.json, logs, SOUL.md reseeded, …).

Path C — Electron spawn guard's bare directoryExists

spawnPoolBackend() guards local spawns with
assertLocalProfileCanStart()directoryExists(profiles/<name>). The
cron shell from Path A satisfies the bare check, so the Path B boot
restore lands a real backend spawn, defeating the guard entirely.

The Fix

Path A — cron/scheduler_provider.py

_start_multiplex now checks Path(home).is_dir() before every
per-profile heartbeat and tick. A missing home is skipped entirely — no
mkdir, no heartbeat, no cron.tick() call, no
set_hermes_home_override. The frozen profile_homes list is left
alone (so the user's profile list still updates on the next gateway
restart); the ticker simply won't act on homes that no longer exist.
Default profile behaviour is unchanged.

Path B — apps/desktop/src/store/connections.ts + delete dialog

New exported helper:

export function forgetLastProfileForAllConnections(profile: string): void

It walks the persisted $lastProfileByConnection record and drops every
entry whose normalized value equals the deleted profile name. The
$lastProfileByConnection subscriber persists the change to
localStorage, so callers don't need to know about the storage layer.

apps/desktop/src/app/profiles/delete-profile-dialog.tsx now invokes it
unconditionally on confirm (before deleteProfile() fires), so
deletes from any workspace — not just the active foreground one —
clean up the cache.

Path C — apps/desktop/electron/profile-delete-routing.ts + main.ts

assertLocalProfileCanStart() gains an optional fourth argument:

profileIdentityMarkerPresent: (profile: string) => boolean = () => true

When set, the function rejects any profile whose home directory lacks a
durable identity marker. main.ts wires this to a new
hasLocalProfileIdentityMarker() helper that looks for any of
config.yaml, SOUL.md, or state.db inside profiles/<name>/. Any
of these proves the home was written by hermes profile create /
ensure_hermes_home() / the live runtime — a bare cron shell has
none, so the spawn guard now rejects it with Profile "<name>" no longer exists.. The default profile is exempt from the identity check
(its home may legitimately lack config.yaml on fresh installs).

The two existing call sites that use the old three-arg signature keep
working because the new argument defaults to "always present" — no
callers needed changes other than the one in spawnPoolBackend().

Evidence

Pre-fix RED → post-fix GREEN

Three regression tests were added against b742be711a (origin/main at
PR creation time); each fails on the unfixed tree and passes with the
fix:

Test File What it pins
test_multiplex_ticker_skips_missing_profile_home_and_does_not_resurrect_it tests/cron/test_scheduler_provider.py Path A — ticker must NOT recreate profiles/<name>/ when it has been deleted between profiles_to_serve() and the next tick. Asserts the directory still doesn't exist after several tick cycles and no cron.tick() is called for the missing home.
assertLocalProfileCanStart rejects a cron-shell home with no durable identity marker (#95188 path C) apps/desktop/electron/profile-delete-routing.test.ts Path C — guard rejects a profile whose home is just a directory shell, even when directoryExists returns true. Also confirms the default profile remains exempt from the identity check.
forgetLastProfileForAllConnections removes the deleted profile from every connection (#95188) apps/desktop/src/store/connections.test.ts Path B — after seeding two connections' caches with the same deleted profile name, the new helper must purge both entries, and the next initializeConnectionsRegistry() boot-restore must dial 'default', not the deleted name.

Test results

$ pytest tests/cron/test_scheduler_provider.py
============================= 31 passed in 4.28s ==============================

$ npx vitest run electron/profile-delete-routing.test.ts src/store/connections.test.ts src/app/profiles/
 Test Files  3 passed (3)
      Tests  45 passed (45)

The pre-fix RED evidence (committed in the same branch) is in the commit
history: the new tests fail on b742be711a (verified locally with the
fix reverted) and pass once the fix is applied.

Typecheck clean

$ cd apps/desktop && npm run typecheck
(runs `tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit`)
exit 0, no diagnostics.

Pre-existing unrelated failures (not introduced by this PR)

The full vitest run on b742be711a already has 31 unrelated failures in
ssh-config, ssh-connection, hardening, windows-hermes-path,
desktop-installation, git-worktree-ops, update-handoff-marker, and
stage-native-deps tests — all environment-specific (POSIX SSH paths,
chmod-bit semantics, PowerShell hand-off timeouts, …) and present
without my changes. Confirmed by running the suite on a clean
git stash'd working tree: same 31 failures. This PR doesn't touch any
of those files.

Verification Steps

  1. Create a bot profile: hermes --desktop → Profiles → Bot Mode →
    "researcher".
  2. Open the Bots pane and verify it appears; verify
    ~/.hermes/profiles/researcher/{config.yaml,SOUL.md,state.db} exist.
  3. From the default workspace (active foreground), open Profiles → ⋯ →
    Delete → confirm for "researcher".
  4. Wait 90 seconds (more than one cron tick + heartbeat cycle).
  5. Verify ~/.hermes/profiles/researcher/ no longer exists at all.
  6. Quit the Desktop app (tray → Quit) and relaunch.
  7. Verify "researcher" does not reappear in the profile rail, the Bots
    pane, or hermes profile list.
  8. Verify ~/.hermes/profiles/researcher/ is still gone.

Without this PR, step 7 shows "researcher" in the profile rail within
~10 s of launch (path B), and steps 4–6 each recreate the cron shell
(path A) and ultimately the full profile (path C).

Related

Closes #95188

Three cooperating paths were resurrecting a deleted named profile:

* Path A (cron ticker): _start_desktop_cron_ticker captured a frozen
  list of profile homes via profiles_to_serve() and the next
  record_ticker_heartbeat() call's ensure_dirs() did
  'mkdir -p profiles/<name>/cron', recreating an empty shell.

* Path B (renderer cache): the delete dialog only reset
  hermes.desktop.lastProfileByConnection when the deleted profile was
  the active foreground one (if (wasActive)). Deleting from another
  workspace left the key pointing at the dead profile forever, so the
  next boot's selectConnection() called ensureGatewayAgent() with the
  deleted profile name and the spawned backend's
  ensure_hermes_home() rebuilt the entire profile tree.

* Path C (electron spawn guard): assertLocalProfileCanStart accepted a
  bare directoryExists(). Combined with path A, an empty cron shell
  was enough to pass the guard and trigger a real backend spawn,
  whose ensure_hermes_home() then resurrected the full profile.

Fix:

* Path A: cron/scheduler_provider._start_multiplex now checks
  Path(home).is_dir() before each per-profile heartbeat and tick; a
  missing home is skipped (no mkdir, no heartbeat, no tick).

* Path B: connections.ts gains forgetLastProfileForAllConnections(),
  called unconditionally from delete-profile-dialog.tsx before the
  delete RPC fires.

* Path C: assertLocalProfileCanStart accepts a new optional
  profileIdentityMarkerPresent predicate. main.ts wires it to a new
  hasLocalProfileIdentityMarker() that looks for config.yaml / SOUL.md
  / state.db inside profiles/<name>/. A bare shell without an identity
  marker now fails the guard with 'Profile <name> no longer exists.'.

Regression tests:
* tests/cron/test_scheduler_provider.py - ticker must skip a deleted
  profile home and must not recreate it.
* apps/desktop/electron/profile-delete-routing.test.ts - guard rejects
  a cron shell without an identity marker.
* apps/desktop/src/store/connections.test.ts - delete must purge the
  cache for every connection regardless of which workspace initiated
  the delete.

Refs NousResearch#95188, NousResearch#94842, NousResearch#94840, NousResearch#89438, NousResearch#90141.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) comp/cron Cron scheduler and job management platform/windows Native Windows-specific behavior or breakage area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 26, 2026

@zhongwater123 zhongwater123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI-generated comment: This comment was produced automatically by AI and may be misleading. Please independently verify the cited evidence.

Verdict: Two cache-invalidation boundaries remain unsafe: this head can discard a valid profile preference even when that profile was not deleted on that source.
Author action: Move invalidation after a successful DELETE and scope it to the actual connection/profile identity.
Independent delta: On exact head 0c0ee25, two DeleteProfileDialog-to-selectConnection regressions failed: a rejected local delete restored default instead of the preserved researcher preference, and a successful local researcher delete also reset homelab's independent researcher preference.

Incremental evidence:

  • Observed: npm exec -- vitest run --project ui src/app/profiles/delete-profile-dialog.review.test.tsx produced 2 failed. In the rejection case, expected local/researcher but received local/default; in the cross-source case, expected homelab/researcher but received homelab/default.
  • Inference: the call in delete-profile-dialog executes before await deleteProfile, and the helper removes every matching profile name without a connection key. That contradicts the existing per-source preference map and multi-source same-name contract.

Coverage limits: Windows 11, Node 22.18.0, npm 10.9.3, Vitest 4.1.10; temporary test removed. I did not independently rerun the cron/spawn suites or a packaged Desktop restart.

// next boot's ``selectConnection()`` calls ``ensureGatewayAgent()``
// with the deleted profile name and the backend's
// ``ensure_hermes_home()`` recreates the whole profile tree.
forgetLastProfileForAllConnections(profile.name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI-generated comment: This comment was produced automatically by AI and may be misleading. Please independently verify the cited evidence.

This invalidates persisted state before the DELETE has succeeded. I reproduced a rejected permission-denied delete on this exact head: the next local switch dialed default instead of the still-valid researcher profile. Please move the targeted invalidation after await deleteProfile returns successfully and add a failed-delete regression.

const next: Record<string, string> = {}

for (const [connectionId, lastProfile] of Object.entries(current)) {
if (normalizeProfileKey(lastProfile) === target) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI-generated comment: This comment was produced automatically by AI and may be misleading. Please independently verify the cited evidence.

Matching only by profile name crosses source boundaries. A local researcher delete on this exact head also removed homelab's independent researcher preference, so the next remote switch dialed default. Please scope invalidation by connectionId plus normalized profile and cover same-name profiles on two connections.

@Finn763

Finn763 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #94842 (fangliquanflq opened earlier on 2026-08-24, 536+ add, 10 files, broader scope) per the canonicalization. Our unique contributions worth absorbing: 1. forgetLastProfileForAllConnections() in connections.ts (path B - cross-workspace cleanup, not in #94842) 2. SQLite WAL/journal awareness in identity-marker detection. Will reopen a smaller PR for path B if #94842 doesn't cover it. Full credit on the duplicate diagnosis already preserved in the issue thread and this PR's diff (8 files, +311/-7).

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/cron Cron scheduler and job management comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

3 participants