Conversation
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.
zhongwater123
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
|
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). |
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. Threecooperating 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_tickercallsprofiles_to_serve()once at backendstartup and passes a frozen list of profile homes to
InProcessCronScheduler._start_multiplex. Every 60 s tick the_atomic_write_epochinsiderecord_ticker_heartbeat()doesmkdir(parents=True, exist_ok=True)onprofiles/<name>/cron/, recreatingthe deleted profile's directory shell. Verified: after
rm -rfof theresurrected 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.tspersists a per-sourcelast-profile map in
localStorage.selectConnection()reads it onboot restore and calls
ensureGatewayAgent(<connection>, <deleted profile>)with no existence check. The delete dialog only reset this keyin the
if (wasActive)branch — deleting from another workspace left thekey pointing at the dead profile forever. On the next desktop restart,
--profile <deleted> serveis spawned, whoseensure_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
directoryExistsspawnPoolBackend()guards local spawns withassertLocalProfileCanStart()→directoryExists(profiles/<name>). Thecron 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_multiplexnow checksPath(home).is_dir()before everyper-profile heartbeat and tick. A missing home is skipped entirely — no
mkdir, no heartbeat, nocron.tick()call, noset_hermes_home_override. The frozenprofile_homeslist is leftalone (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 dialogNew exported helper:
It walks the persisted
$lastProfileByConnectionrecord and drops everyentry whose normalized value equals the deleted profile name. The
$lastProfileByConnectionsubscriber persists the change tolocalStorage, so callers don't need to know about the storage layer.apps/desktop/src/app/profiles/delete-profile-dialog.tsxnow invokes itunconditionally on confirm (before
deleteProfile()fires), sodeletes from any workspace — not just the active foreground one —
clean up the cache.
Path C —
apps/desktop/electron/profile-delete-routing.ts+main.tsassertLocalProfileCanStart()gains an optional fourth argument:When set, the function rejects any profile whose home directory lacks a
durable identity marker.
main.tswires this to a newhasLocalProfileIdentityMarker()helper that looks for any ofconfig.yaml,SOUL.md, orstate.dbinsideprofiles/<name>/. Anyof these proves the home was written by
hermes profile create/ensure_hermes_home()/ the live runtime — a bare cron shell hasnone, 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.yamlon 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 atPR creation time); each fails on the unfixed tree and passes with the
fix:
test_multiplex_ticker_skips_missing_profile_home_and_does_not_resurrect_ittests/cron/test_scheduler_provider.pyprofiles/<name>/when it has been deleted betweenprofiles_to_serve()and the next tick. Asserts the directory still doesn't exist after several tick cycles and nocron.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.tsdirectoryExistsreturns 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.tsinitializeConnectionsRegistry()boot-restore must dial'default', not the deleted name.Test results
The pre-fix RED evidence (committed in the same branch) is in the commit
history: the new tests fail on
b742be711a(verified locally with thefix reverted) and pass once the fix is applied.
Typecheck clean
Pre-existing unrelated failures (not introduced by this PR)
The full vitest run on
b742be711aalready has 31 unrelated failures inssh-config,ssh-connection,hardening,windows-hermes-path,desktop-installation,git-worktree-ops,update-handoff-marker, andstage-native-depstests — 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 anyof those files.
Verification Steps
hermes --desktop→ Profiles → Bot Mode →"researcher".
~/.hermes/profiles/researcher/{config.yaml,SOUL.md,state.db}exist.defaultworkspace (active foreground), open Profiles → ⋯ →Delete → confirm for "researcher".
~/.hermes/profiles/researcher/no longer exists at all.pane, or
hermes profile list.~/.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
other half of the deletion chain).
guard rather than introducing a new tombstone file format.
sessionSeenCounts; Desktop: deleted profile resurrects via two cooperating paths — stale lastProfileByConnection + cron-ticker shell defeating the spawn guard (Win, v0.20.5) #95188 calls out that this issue isspecifically the uncovered
lastProfileByConnectionpath.Closes #95188