Skip to content

fix(desktop-update): drive the Windows hand-off through the venv python, not the hermes.exe shim - #85679

Closed
shaase-ctrl wants to merge 1 commit into
NousResearch:mainfrom
shaase-ctrl:fix/desktop-update-avoid-shim
Closed

fix(desktop-update): drive the Windows hand-off through the venv python, not the hermes.exe shim#85679
shaase-ctrl wants to merge 1 commit into
NousResearch:mainfrom
shaase-ctrl:fix/desktop-update-avoid-shim

Conversation

@shaase-ctrl

Copy link
Copy Markdown

Problem

scripts/desktop-update/windows.ps1 runs the update as
venv\Scripts\hermes.exe update --yes --gateway --force. uv pip install -e .
then has to replace the console-script shims, so _quarantine_running_hermes_exe
must rename the running hermes.exe out of the way first.

That rename fails whenever any child process spawned from that hermes.exe is
still alive
— on Windows a child inherits a handle on the parent image. It is
the inherited handle, not the trampoline, that pins the file.

When the rename fails, _schedule_replace_on_reboot is the last resort, but
MOVEFILE_DELAY_UNTIL_REBOOT writes to
HKLM\System\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations
and therefore requires elevation. A Desktop-driven update is not elevated, so it
returns ERROR_ACCESS_DENIED, uv pip install -e . exits 2, and the ZIP
fallback repeats the identical sequence.

The consequence is not a skipped update. The desktop build stage is never
reached while the pre-build clean has already removed apps/desktop/release, so
the install is left with a Start Menu shortcut pointing at a Hermes.exe that no
longer exists. That is how one install here lost its Desktop app entirely on
2026-08-13; the CLI and gateway kept running, which made it look like an app bug
rather than a failed update.

Measurements

Windows 11 26200, non-elevated session, hermes-agent 0.20.1, uv 0.12.1.

Renaming venv\Scripts\hermes.exe while a process runs from it:

Situation Rename-Item
hermes.exe serve running, its child python.exe alive blocked (sharing violation)
same parent, child killed first succeeds
nothing running from the shim succeeds

Shim flavour is irrelevant — a uv trampoline (46080 B) and a distlib launcher
(108436 B, hindsight-api.exe) are pinned identically.

Reboot fallback, non-elevated:

[P.K32]::MoveFileExW($src, $dst, 0x1 -bor 0x4)  # REPLACE_EXISTING | DELAY_UNTIL_REBOOT
# -> False, GetLastError = 5 (ERROR_ACCESS_DENIED)

The account is in the local Administrators group; the session carries the
filtered UAC token (EnableLUA=1, ConsentPromptBehaviorAdmin=5).

Why it reads as flaky

logs/desktop-update-handoff.log, one machine, one code path:

2026-08-11T20:11:30  running: hermes update --yes --gateway --force --branch main
2026-08-11T20:15:00  hermes update exit code: 0
2026-08-13T20:18:52  running: hermes update --yes --gateway --force --branch main
2026-08-13T20:22:33  hermes update exit code: 0
2026-08-13T20:37:18  running: hermes update --yes --gateway --force --branch main
2026-08-13T20:39:05  hermes update exit code: 1
2026-08-13T20:40:50  retry exit code: 1

Eighteen minutes apart: success, then hard failure. The only variable is whether a
long-lived child is alive when the quarantine runs. The reliable producers are the
updater's own steps — the npx cache warm and the memory-provider refresh
(hindsight-api runs as a daemon with --idle-timeout 300, so it outlives the
step that started it). Installs whose memory.provider spawns no daemon rarely
hit this, which likely explains the low report rate.

Step 2's preflight (File.Open(shim, 'Open', 'ReadWrite', 'None')) cannot catch
it: the shim genuinely is unlocked at that moment. The pinning child appears
later, during the update.

Fix

Invoke the same code through the venv interpreter, so the inherited handles land
on python.exe — which uv never has to replace. Three call sites: the update,
its retry, and the desktop --force-build --build-only rebuild.

scripts/desktop-update/posix.sh is deliberately untouched: unlinking a running
executable is legal there, so the equivalent call is harmless.

Verification

The manual equivalent (venv\Scripts\python.exe -m hermes_cli.main update --yes)
completed with exit 0 on the first attempt after three consecutive shim-driven
failures on the same install, and the subsequent
python.exe -m hermes_cli.main desktop --force-build --build-only rebuilt
release/win-unpacked/Hermes.exe cleanly.

No test changes: apps/desktop/electron/updater-process.test.ts asserts the
hand-off script's path, not its contents, and CI's PowerShell job covers
scripts/install.ps1 only.

Possibly worth a follow-up

  1. _quarantine_running_hermes_exe could reap or wait for its own descendants
    before renaming, rather than depending on the reboot fallback.
  2. The reboot fallback's elevation requirement is undocumented at the call site.
    When it returns False the warning tells the user to close Hermes Desktop,
    exit other REPLs or stop the gateway — misleading when the actual holder is a
    grandchild of the updater itself.

🤖 Generated with Claude Code

…on, not the hermes.exe shim

`uv pip install -e .` has to replace the console-script shims, so
_quarantine_running_hermes_exe must first rename the running hermes.exe out
of the way. That rename fails whenever any child process spawned from that
hermes.exe is still alive: on Windows a child inherits a handle on the parent
image. It is the inherited handle, not the trampoline, that pins the file --
killing the child makes the identical rename succeed, and the shim flavour
(uv trampoline vs distlib launcher) makes no difference.

The updater spawns such children itself (npx cache warm, memory-provider
refresh -- hindsight-api runs as a daemon with --idle-timeout 300 and outlives
the step that started it), so this presents as a race rather than a hard
failure: the same hand-off succeeds on one run and dies on the next. Step 2's
shim-unlock preflight cannot catch it, because the shim genuinely is unlocked
at that moment; the pinning child appears later, during the update.

When the rename loses that race, _schedule_replace_on_reboot is the last
resort -- and MOVEFILE_DELAY_UNTIL_REBOOT writes to HKLM, so it needs
elevation. A Desktop-driven update is not elevated, so it returns
ERROR_ACCESS_DENIED, `uv pip install -e .` exits 2, and the ZIP fallback
repeats the identical sequence. The desktop build stage is then never reached
while the pre-build clean has already removed apps/desktop/release, leaving an
install whose Start Menu shortcut points at a Hermes.exe that no longer exists.

Running the same code as `python.exe -m hermes_cli.main update` puts the
inherited handles on python.exe, which uv never has to replace.

posix.sh is deliberately untouched: unlinking a running executable is legal
there, so the equivalent call is harmless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) platform/windows Native Windows-specific behavior or breakage P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 13, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #86326, which takes the same off-the-shim approach and adds a source-level regression guard. Credited you as co-author there. Thanks for the fix and the thorough write-up.

@Halldrix

Copy link
Copy Markdown
Contributor

Validated on a real Windows 11 host (git checkout, Python 3.11.15 venv). This
fix works, and it rebases cleanly.

Validation performed:

  1. Rebased this branch onto current origin/mainclean, no conflicts
    (lands as a single commit on top).

  2. Ran a real update through the new entrypoint this PR introduces:

    venv\Scripts\python.exe -m hermes_cli.main update --yes --branch main
    

    The update ran and uv pip install -e . rewrote the console-script shim
    cleanly — the shim's SHA-256 changed and it stayed present and working.
    Zero self-lock markers in the output:

    Marker (present pre-fix) After this PR
    Could not quarantine hermes.exe absent
    os error 32 (being used by another process) absent
    os error 5 (Access is denied) absent

Why the entrypoint (not detection) is the cure:
_detect_venv_python_processes deliberately excludes the calling updater
(update_cmd.py:2935 — "a CLI hermes update itself runs from the venv
python"), and here the holder is the launcher itself, which no preflight can
exclude. Moving the update's own process onto python.exe (which uv never
replaces) removes the hazard instead of racing it. Field-confirmed on a second
host that this is exactly the chain in #86223.

Complement, not duplicate, of #86101: this fixes the Desktop-driven
hand-off (windows.ps1). #86101 fixes the plain CLI path by trampolining
cmd_update out of its launcher. I verified the two rebase cleanly onto each
other (git merge-tree — no conflicts), so both can land. Suggest the bodies
cross-link so a maintainer knows each covers a different door into the same
self-lock.

Happy to run any further scenario on the Windows host if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants