fix(desktop-update): drive the Windows hand-off through the venv python, not the hermes.exe shim - #85679
Conversation
…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>
|
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. |
|
Validated on a real Windows 11 host (git checkout, Python 3.11.15 venv). This Validation performed:
Why the entrypoint (not detection) is the cure: Complement, not duplicate, of #86101: this fixes the Desktop-driven Happy to run any further scenario on the Windows host if useful. |
Problem
scripts/desktop-update/windows.ps1runs the update asvenv\Scripts\hermes.exe update --yes --gateway --force.uv pip install -e .then has to replace the console-script shims, so
_quarantine_running_hermes_exemust rename the running
hermes.exeout of the way first.That rename fails whenever any child process spawned from that
hermes.exeisstill 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_rebootis the last resort, butMOVEFILE_DELAY_UNTIL_REBOOTwrites toHKLM\System\CurrentControlSet\Control\Session Manager\PendingFileRenameOperationsand therefore requires elevation. A Desktop-driven update is not elevated, so it
returns
ERROR_ACCESS_DENIED,uv pip install -e .exits 2, and the ZIPfallback 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, sothe install is left with a Start Menu shortcut pointing at a
Hermes.exethat nolonger 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.exewhile a process runs from it:Rename-Itemhermes.exe serverunning, its childpython.exealiveShim flavour is irrelevant — a uv trampoline (46080 B) and a distlib launcher
(108436 B,
hindsight-api.exe) are pinned identically.Reboot fallback, non-elevated:
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: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-apiruns as a daemon with--idle-timeout 300, so it outlives thestep that started it). Installs whose
memory.providerspawns no daemon rarelyhit this, which likely explains the low report rate.
Step 2's preflight (
File.Open(shim, 'Open', 'ReadWrite', 'None')) cannot catchit: 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-onlyrebuild.scripts/desktop-update/posix.shis deliberately untouched: unlinking a runningexecutable 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-onlyrebuiltrelease/win-unpacked/Hermes.execleanly.No test changes:
apps/desktop/electron/updater-process.test.tsasserts thehand-off script's path, not its contents, and CI's PowerShell job covers
scripts/install.ps1only.Possibly worth a follow-up
_quarantine_running_hermes_execould reap or wait for its own descendantsbefore renaming, rather than depending on the reboot fallback.
When it returns
Falsethe 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