{"repo":"NousResearch/hermes-agent","public_id":"45542","display_label":"Windows auto-update infinite retry loop and self-lock recovery","summary":"The three members all concern an infinite auto-update or recovery loop on Windows, involving a .update-incomplete marker and hermes.exe locking itself. One is a bug report and two are fix proposals (desktop and CLI); the titles do not show whether any change has been merged or has worked.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54135,"merged_at":"2026-06-28T09:40:38+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:40:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":38122,"pr_number":54135,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T09:40:38+00:00"},{"issue_number":45542,"pr_number":54135,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T09:40:38+00:00"}],"members":[{"kind":"issue","number":45542,"title":"[Bug]: .update-incomplete marker causes infinite auto-recovery loop — hermes.exe self-locked on Windows","url":null,"state":"closed"},{"kind":"pr","number":45554,"title":"fix(cli): detect self-lock in update recovery to break infinite retry…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54135","number":54135},{"kind":"pr","value":"#52378","number":52378}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54135,"title":"fix(windows): break the desktop auto-update infinite loop (#52378)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/backend-probes.cjs","apps/desktop/electron/backend-probes.test.cjs","apps/desktop/electron/main.cjs","apps/desktop/electron/windows-hermes-resolution.test.cjs","apps/desktop/package.json","hermes_cli/main.py","infographic/windows-update-loop-52378/infographic.png","scripts/release.py","tests/hermes_cli/test_update_interrupted_recovery.py"],"fix_hunks":[{"file":"apps/desktop/electron/backend-probes.cjs","hunks":["@@ -37,7 +37,18 @@ const { execFileSync } = require('node:child_process')\n const PROBE_TIMEOUT_MS = 5000\n \n /**\n- * Return true iff `python -c \"import hermes_cli\"` exits 0.\n+ * Return the Python snippet used to verify Hermes can import far enough to\n+ * launch the CLI. Kept exported for tests so dependency regressions are\n+ * caught without needing a real broken venv fixture.\n+ *\n+ * @returns {string}\n+ */\n+function hermesRuntimeImportProbe() {\n+  return 'import yaml; import hermes_cli.config'\n+}\n+\n+/**\n+ * Return true iff the Hermes runtime import probe exits 0.\n  *\n  * Used to gate the \"fallback to system Python with hermes_cli installed\"\n  * rung of resolveHermesBackend. Without this, a system Python 3.11-3.13","@@ -46,13 +57,20 @@ const PROBE_TIMEOUT_MS = 5000\n  * site-packages -- and the resolver returns a backend that immediately\n  * dies on spawn.\n  *\n+ * The probe intentionally imports hermes_cli.config, not just the top-level\n+ * package: a broken/empty Windows launcher venv can still see the source tree\n+ * through PYTHONPATH but lack PyYAML, then die on the first real CLI import.\n+ *\n  * @param {string} pythonPath - Absolute path to a python.exe / python.\n+ * @param {object} [opts]\n+ * @param {object} [opts.env] - Additional environment for the probe.\n  * @returns {boolean}\n  */\n-function canImportHermesCli(pythonPath) {\n+function canImportHermesCli(pythonPath, opts = {}) {\n   if (!pythonPath) return false\n   try {\n-    execFileSync(pythonPath, ['-c', 'import hermes_cli'], {\n+    execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {\n+      env: { ...process.env, ...(opts.env || {}) },\n       stdio: 'ignore',\n       timeout: PROBE_TIMEOUT_MS,\n       windowsHide: true","@@ -101,6 +119,7 @@ function verifyHermesCli(hermesCommand, opts = {}) {\n \n module.exports = {\n   canImportHermesCli,\n+  hermesRuntimeImportProbe,\n   verifyHermesCli,\n   PROBE_TIMEOUT_MS\n }"]},{"file":"apps/desktop/electron/backend-probes.test.cjs","hunks":["@@ -11,7 +11,7 @@ const fs = require('node:fs')\n const os = require('node:os')\n const path = require('node:path')\n \n-const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')\n+const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')\n \n // Resolve the host's own Node binary -- guaranteed to be on disk and\n // runnable. We use it as both a stand-in for \"a python that doesn't","@@ -40,6 +40,12 @@ test('canImportHermesCli returns false when binary does not exist', () => {\n   assert.equal(canImportHermesCli(ghost), false)\n })\n \n+test('hermes runtime import probe checks config dependencies', () => {\n+  const probe = hermesRuntimeImportProbe()\n+  assert.match(probe, /\\bimport yaml\\b/)\n+  assert.match(probe, /\\bimport hermes_cli\\.config\\b/)\n+})\n+\n test('verifyHermesCli returns false when command is falsy', () => {\n   assert.equal(verifyHermesCli(''), false)\n   assert.equal(verifyHermesCli(null), false)"]},{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -1285,8 +1285,14 @@ function findOnPath(command) {\n   const pathEntries = String(process.env.PATH || '')\n     .split(path.delimiter)\n     .filter(Boolean)\n+  // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name.\n+  // A real command must resolve via its .exe/.cmd (Windows command-resolution\n+  // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash\n+  // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.\n+  // The empty entry is kept LAST so callers that already include the extension\n+  // (py.exe, pwsh.exe, powershell.exe) still resolve.\n   const extensions = IS_WINDOWS\n-    ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]\n+    ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']\n     : ['']\n \n   for (const entry of pathEntries) {","@@ -2243,7 +2249,18 @@ async function handOffWindowsBootstrapRecovery(reason) {\n     : configuredBranch || DEFAULT_UPDATE_BRANCH\n   const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')\n   const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')\n-  const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]\n+  const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python')\n+  // Choose the gentle in-place --update when ANY real-install signal is present,\n+  // not just the `hermes.exe` console-script shim. That shim is generated at the\n+  // END of venv setup and is absent in exactly the interrupted/quarantined states\n+  // this recovery exists to heal — gating on it alone forced the destructive\n+  // --repair (full venv recreate) and drove reinstall loops. The venv interpreter\n+  // and the bootstrap-complete marker are present earlier and are better signals.\n+  const haveRealInstall =\n+    fileExists(venvPython) ||\n+    fileExists(venvHermes) ||\n+    fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))\n+  const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]\n \n   await releaseBackendLockForUpdate(updateRoot)\n ","@@ -2608,6 +2625,24 @@ function readBootstrapMarker() {\n   return readJson(BOOTSTRAP_COMPLETE_MARKER)\n }\n \n+// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually\n+// runnable right now? A complete CLI install (`install.sh --include-desktop`)\n+// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop\n+// ever having written the bootstrap marker -- so we must be able to recognise\n+// \"already installed\" off the filesystem alone, not just the marker.\n+function isActiveRuntimeUsable() {\n+  const venvPython = getVenvPython(VENV_ROOT)\n+  return (\n+    isHermesSourceRoot(ACTIVE_HERMES_ROOT) &&\n+    fileExists(venvPython) &&\n+    canImportHermesCli(venvPython, {\n+      env: {\n+        PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)\n+      }\n+    })\n+  )\n+}\n+\n function isBootstrapComplete() {\n   const marker = readBootstrapMarker()\n   if (!marker || typeof marker !== 'object') return false","@@ -2620,7 +2655,7 @@ function isBootstrapComplete() {\n   // a runnable venv: an interrupted or split-home install can leave the marker\n   // + checkout without a venv, and trusting that spawns a dead backend\n   // (\"gateway offline\") instead of re-running bootstrap to repair it.\n-  return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))\n+  return isActiveRuntimeUsable()\n }\n \n function writeBootstrapMarker(payload) {"]},{"file":"apps/desktop/electron/windows-hermes-resolution.test.cjs","hunks":["@@ -0,0 +1,67 @@\n+'use strict'\n+\n+// Regression guards for Windows `hermes` resolution in main.cjs.\n+//\n+// main.cjs has no module.exports, so these follow the repo's source-assertion\n+// test pattern (see windows-child-process.test.cjs). They pin the two Windows\n+// resolution bugs that caused desktop reinstall loops:\n+//   1. findOnPath() tried the empty extension FIRST, so an extensionless\n+//      Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the\n+//      shim then failed the --version probe and the desktop fell through to a\n+//      spurious bootstrap/repair.\n+//   2. handOffWindowsBootstrapRecovery() chose --update vs the destructive\n+//      --repair by checking ONLY venv\\Scripts\\hermes.exe (the console-script\n+//      shim, written at the END of venv setup and absent in interrupted\n+//      states), so it escalated to a full venv recreate even on healthy\n+//      installs.\n+\n+const test = require('node:test')\n+const assert = require('node:assert/strict')\n+const fs = require('node:fs')\n+const path = require('node:path')\n+\n+function readMain() {\n+  return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\\r\\n/g, '\\n')\n+}\n+\n+test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {\n+  const source = readMain()\n+  // Fixed order: PATHEXT first, empty string LAST.\n+  assert.match(\n+    source,\n+    /\\(process\\.env\\.PATHEXT \\|\\| '\\.COM;\\.EXE;\\.BAT;\\.CMD'\\)\\.split\\(';'\\)\\.filter\\(Boolean\\), ''\\]/,\n+    'extensions array must end with the empty string, not start with it'\n+  )\n+  // The buggy empty-first order must not return.\n+  assert.doesNotMatch(\n+    source,\n+    /\\['', \\.\\.\\.\\(process\\.env\\.PATHEXT/,\n+    'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'\n+  )\n+})\n+\n+test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {\n+  const source = readMain()\n+  assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')\n+  assert.match(\n+    source,\n+    /fileExists\\(venvPython\\)/,\n+    'recovery must accept the venv interpreter as a real-install signal'\n+  )\n+  assert.match(\n+    source,\n+    /\\.hermes-bootstrap-complete/,\n+    'recovery must accept the bootstrap-complete marker as a real-install signal'\n+  )\n+  assert.match(\n+    source,\n+    /updaterArgs = haveRealInstall \\? \\['--update'/,\n+    'updaterArgs must gate on haveRealInstall'\n+  )\n+  // The old too-narrow check (only venv\\Scripts\\hermes.exe) must not return.\n+  assert.doesNotMatch(\n+    source,\n+    /updaterArgs = fileExists\\(venvHermes\\) \\?/,\n+    'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'\n+  )\n+})"]},{"file":"apps/desktop/package.json","hunks":["@@ -37,7 +37,7 @@\n     \"test:desktop:nsis\": \"node scripts/test-desktop.mjs nsis\",\n     \"test:desktop:existing\": \"node scripts/test-desktop.mjs existing\",\n     \"test:desktop:fresh\": \"node scripts/test-desktop.mjs fresh\",\n-    \"test:desktop:platforms\": \"node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs\",\n+    \"test:desktop:platforms\": \"node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs\",\n     \"typecheck\": \"tsc -p . --noEmit\",\n     \"lint\": \"eslint src/ electron/\",\n     \"lint:fix\": \"eslint src/ electron/ --fix\","]},{"file":"hermes_cli/main.py","hunks":["@@ -6992,6 +6992,55 @@ def _recover_from_interrupted_install() -> None:\n         # the install itself will surface the real problem.\n         logger.debug(\"Could not create install-recovery lock: %s\", exc)\n \n+    # Windows self-lock guard: if hermes.exe is the launcher that spawned\n+    # this Python process, any attempt to pip-install will fail with\n+    # \"拒绝访问 / WinError 32\" because the running .exe cannot be replaced.\n+    # Rather than entering the permanent retry loop described in issue\n+    # #45542, clear the marker and give the user an offline recovery command.\n+    if _is_windows():\n+        scripts_dir = _venv_scripts_dir()\n+        if scripts_dir is not None:\n+            shims = _hermes_exe_shims(scripts_dir)\n+            if shims:\n+                _shim_set: set[str] = set()\n+                for _s in shims:\n+                    try:\n+                        _shim_set.add(str(_s.resolve()).lower())\n+                    except OSError:\n+                        _shim_set.add(str(_s).lower())\n+                try:\n+                    import psutil\n+                    _me = psutil.Process()\n+                    for _anc in [_me] + list(_me.parents()):\n+                        try:\n+                            _anc_exe = _anc.exe()\n+                            _anc_norm = str(Path(_anc_exe).resolve()).lower()\n+                        except Exception:\n+                            continue\n+                        if _anc_norm in _shim_set:\n+                            print(\n+                                \"✗ Hermes is running from the binary that \"\n+                                \"needs to be replaced — the auto-recovery \"\n+                                \"cannot overwrite a running executable.\"\n+                            )\n+                            print(\n+                                \"  Restart Hermes from a different terminal, \"\n+                                \"then run the manual recovery command below:\"\n+                            )\n+                            print(f'    cd /d \"{PROJECT_ROOT}\"')\n+                            print(\n+                                f'    \"{sys.executable}\" -m pip install '\n+                                '-e \".[all]\"'\n+                            )\n+                            _clear_update_incomplete_marker()\n+                            try:\n+                                lock_path.unlink()\n+                            except OSError:\n+                                pass\n+                            return\n+                except Exception:\n+                    pass  # psutil is best-effort; fall through to install\n+\n     saved_stdout_fd = None\n     saved_sys_stdout = sys.stdout\n     try:"]},{"file":"scripts/release.py","hunks":["@@ -167,6 +167,8 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"121278003+Cossackx@users.noreply.github.com\": \"Cossackx\",  # PR #52528 salvage (Windows hermes-shim resolution + prefer --update on recovery; #52378)\n+    \"97326386+Icather@users.noreply.github.com\": \"Icather\",  # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542)\n     \"--email\": \"[redacted:email]\",\n     \"[redacted:email]\": \"francescomucio\",\n     \"291572938+thestral123@users.noreply.github.com\": \"thestral123\","]},{"file":"tests/hermes_cli/test_update_interrupted_recovery.py","hunks":["@@ -139,6 +139,90 @@ class R:\n     )\n \n \n+def test_recovery_self_lock_guard_clears_marker_without_install(tmp_path, monkeypatch):\n+    # Windows self-lock: hermes.exe is an ancestor of this Python process, so a\n+    # pip-install would fail trying to replace the running launcher (WinError 32\n+    # / 拒绝访问). Recovery must short-circuit — clear the marker, skip install,\n+    # break the loop (#45542 / #52378) — instead of retrying forever.\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    (tmp_path / \"pyproject.toml\").write_text(\"[project]\\nname='x'\\n\")\n+    m._write_update_incomplete_marker()\n+\n+    scripts_dir = tmp_path / \"venv\" / \"Scripts\"\n+    scripts_dir.mkdir(parents=True)\n+    shim = scripts_dir / \"hermes.exe\"\n+    shim.write_text(\"\")\n+\n+    monkeypatch.setattr(m, \"_is_windows\", lambda: True)\n+    monkeypatch.setattr(m, \"_venv_scripts_dir\", lambda: scripts_dir)\n+    monkeypatch.setattr(m, \"_hermes_exe_shims\", lambda d: [shim])\n+\n+    class FakeProc:\n+        def __init__(self, exe_path):\n+            self._exe = exe_path\n+\n+        def exe(self):\n+            return self._exe\n+\n+        def parents(self):\n+            return [FakeProc(str(shim))]\n+\n+    monkeypatch.setattr(\"psutil.Process\", lambda: FakeProc(sys_executable_path()))\n+\n+    seen = {\"install\": False}\n+    _stub_install_env(monkeypatch, m, seen)\n+\n+    m._recover_from_interrupted_install()\n+\n+    assert seen[\"install\"] is False, \"self-lock must skip the install\"\n+    assert not m._update_marker_path().exists(), \"marker cleared to break the loop\"\n+\n+\n+def sys_executable_path():\n+    import sys\n+\n+    return sys.executable\n+\n+\n+def test_recovery_self_lock_guard_inactive_when_not_ancestor(tmp_path, monkeypatch):\n+    # Windows, but hermes.exe is NOT in the ancestry (launched via `hermes\n+    # dashboard` from a separate cmd, say). The guard must fall through to the\n+    # normal install so a genuinely interrupted install still gets healed.\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    (tmp_path / \"pyproject.toml\").write_text(\"[project]\\nname='x'\\n\")\n+    m._write_update_incomplete_marker()\n+\n+    scripts_dir = tmp_path / \"venv\" / \"Scripts\"\n+    scripts_dir.mkdir(parents=True)\n+    shim = scripts_dir / \"hermes.exe\"\n+    shim.write_text(\"\")\n+\n+    monkeypatch.setattr(m, \"_is_windows\", lambda: True)\n+    monkeypatch.setattr(m, \"_venv_scripts_dir\", lambda: scripts_dir)\n+    monkeypatch.setattr(m, \"_hermes_exe_shims\", lambda d: [shim])\n+\n+    class FakeProc:\n+        def __init__(self, exe_path):\n+            self._exe = exe_path\n+\n+        def exe(self):\n+            return self._exe\n+\n+        def parents(self):\n+            # Ancestry is plain pythons / cmd — no hermes.exe shim.\n+            return [FakeProc(str(tmp_path / \"cmd.exe\"))]\n+\n+    monkeypatch.setattr(\"psutil.Process\", lambda: FakeProc(sys_executable_path()))\n+\n+    seen = {\"install\": False}\n+    _stub_install_env(monkeypatch, m, seen)\n+\n+    m._recover_from_interrupted_install()\n+\n+    assert seen[\"install\"] is True, \"without self-lock, normal recovery runs\"\n+    assert not m._update_marker_path().exists(), \"marker cleared on successful install\"\n+\n+\n def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):\n     # Another process is mid-recovery (fresh lockfile) — this launch must skip\n     # the install entirely and leave both marker and lock untouched.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}