# NousResearch/hermes-agent · Windows auto-update infinite retry loop and self-lock recovery

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #54135. Verify it addresses your case.
- **Recommended:** read PR #54135 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #45542: \[Bug\]: .update-incomplete marker causes infinite auto-recovery loop — hermes.exe self-locked on Windows

## Prior & competing PRs

- **#54135: fix(windows): break the desktop auto-update infinite loop (#52378)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `apps/desktop/electron/backend-probes.cjs`

    ```diff
    @@ -37,7 +37,18 @@ const { execFileSync } = require('node:child_process')
     const PROBE_TIMEOUT_MS = 5000
     
     /**
    - * Return true iff `python -c "import hermes_cli"` exits 0.
    + * Return the Python snippet used to verify Hermes can import far enough to
    + * launch the CLI. Kept exported for tests so dependency regressions are
    + * caught without needing a real broken venv fixture.
    + *
    + * @returns {string}
    + */
    +function hermesRuntimeImportProbe() {
    +  return 'import yaml; import hermes_cli.config'
    +}
    +
    +/**
    + * Return true iff the Hermes runtime import probe exits 0.
      *
      * Used to gate the "fallback to system Python with hermes_cli installed"
      * rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
    ```

    ```diff
    @@ -46,13 +57,20 @@ const PROBE_TIMEOUT_MS = 5000
      * site-packages -- and the resolver returns a backend that immediately
      * dies on spawn.
      *
    + * The probe intentionally imports hermes_cli.config, not just the top-level
    + * package: a broken/empty Windows launcher venv can still see the source tree
    + * through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
    + *
      * @param {string} pythonPath - Absolute path to a python.exe / python.
    + * @param {object} [opts]
    + * @param {object} [opts.env] - Additional environment for the probe.
      * @returns {boolean}
      */
    -function canImportHermesCli(pythonPath) {
    +function canImportHermesCli(pythonPath, opts = {}) {
       if (!pythonPath) return false
       try {
    -    execFileSync(pythonPath, ['-c', 'import hermes_cli'], {
    +    execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
    +      env: { ...process.env, ...(opts.env || {}) },
           stdio: 'ignore',
           timeout: PROBE_TIMEOUT_MS,
           windowsHide: true
    ```

    ```diff
    @@ -101,6 +119,7 @@ function verifyHermesCli(hermesCommand, opts = {}) {
     
     module.exports = {
       canImportHermesCli,
    +  hermesRuntimeImportProbe,
       verifyHermesCli,
       PROBE_TIMEOUT_MS
     }
    ```
  - `apps/desktop/electron/backend-probes.test.cjs`

    ```diff
    @@ -11,7 +11,7 @@ const fs = require('node:fs')
     const os = require('node:os')
     const path = require('node:path')
     
    -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
    +const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
     
     // Resolve the host's own Node binary -- guaranteed to be on disk and
     // runnable. We use it as both a stand-in for "a python that doesn't
    ```

    ```diff
    @@ -40,6 +40,12 @@ test('canImportHermesCli returns false when binary does not exist', () => {
       assert.equal(canImportHermesCli(ghost), false)
     })
     
    +test('hermes runtime import probe checks config dependencies', () => {
    +  const probe = hermesRuntimeImportProbe()
    +  assert.match(probe, /\bimport yaml\b/)
    +  assert.match(probe, /\bimport hermes_cli\.config\b/)
    +})
    +
     test('verifyHermesCli returns false when command is falsy', () => {
       assert.equal(verifyHermesCli(''), false)
       assert.equal(verifyHermesCli(null), false)
    ```
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -1285,8 +1285,14 @@ function findOnPath(command) {
       const pathEntries = String(process.env.PATH || '')
         .split(path.delimiter)
         .filter(Boolean)
    +  // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name.
    +  // A real command must resolve via its .exe/.cmd (Windows command-resolution
    +  // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash
    +  // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
    +  // The empty entry is kept LAST so callers that already include the extension
    +  // (py.exe, pwsh.exe, powershell.exe) still resolve.
       const extensions = IS_WINDOWS
    -    ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
    +    ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
         : ['']
     
       for (const entry of pathEntries) {
    ```

    ```diff
    @@ -2243,7 +2249,18 @@ async function handOffWindowsBootstrapRecovery(reason) {
         : configuredBranch || DEFAULT_UPDATE_BRANCH
       const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
       const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
    -  const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
    +  const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python')
    +  // Choose the gentle in-place --update when ANY real-install signal is present,
    +  // not just the `hermes.exe` console-script shim. That shim is generated at the
    +  // END of venv setup and is absent in exactly the interrupted/quarantined states
    +  // this recovery exists to heal — gating on it alone forced the destructive
    +  // --repair (full venv recreate) and drove reinstall loops. The venv interpreter
    +  // and the bootstrap-complete marker are present earlier and are better signals.
    +  const haveRealInstall =
    +    fileExists(venvPython) ||
    +    fileExists(venvHermes) ||
    +    fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
    +  const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
     
       await releaseBackendLockForUpdate(updateRoot)
     
    ```

    ```diff
    @@ -2608,6 +2625,24 @@ function readBootstrapMarker() {
       return readJson(BOOTSTRAP_COMPLETE_MARKER)
     }
     
    +// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
    +// runnable right now? A complete CLI install (`install.sh --include-desktop`)
    +// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
    +// ever having written the bootstrap marker -- so we must be able to recognise
    +// "already installed" off the filesystem alone, not just the marker.
    +function isActiveRuntimeUsable() {
    +  const venvPython = getVenvPython(VENV_ROOT)
    +  return (
    +    isHermesSourceRoot(ACTIVE_HERMES_ROOT) &&
    +    fileExists(venvPython) &&
    +    canImportHermesCli(venvPython, {
    +      env: {
    +        PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
    +      }
    +    })
    +  )
    +}
    +
     function isBootstrapComplete() {
       const marker = readBootstrapMarker()
       if (!marker || typeof marker !== 'object') return false
    ```

    ```diff
    @@ -2620,7 +2655,7 @@ function isBootstrapComplete() {
       // a runnable venv: an interrupted or split-home install can leave the marker
       // + checkout without a venv, and trusting that spawns a dead backend
       // ("gateway offline") instead of re-running bootstrap to repair it.
    -  return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
    +  return isActiveRuntimeUsable()
     }
     
     function writeBootstrapMarker(payload) {
    ```
  - `apps/desktop/electron/windows-hermes-resolution.test.cjs`

    ```diff
    @@ -0,0 +1,67 @@
    +'use strict'
    +
    +// Regression guards for Windows `hermes` resolution in main.cjs.
    +//
    +// main.cjs has no module.exports, so these follow the repo's source-assertion
    +// test pattern (see windows-child-process.test.cjs). They pin the two Windows
    +// resolution bugs that caused desktop reinstall loops:
    +//   1. findOnPath() tried the empty extension FIRST, so an extensionless
    +//      Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
    +//      shim then failed the --version probe and the desktop fell through to a
    +//      spurious bootstrap/repair.
    +//   2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
    +//      --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
    +//      shim, written at the END of venv setup and absent in interrupted
    +//      states), so it escalated to a full venv recreate even on healthy
    +//      installs.
    +
    +const test = require('node:test')
    +const assert = require('node:assert/strict')
    +const fs = require('node:fs')
    +const path = require('node:path')
    +
    +function readMain() {
    +  return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n')
    +}
    +
    +test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
    +  const source = readMain()
    +  // Fixed order: PATHEXT first, empty string LAST.
    +  assert.match(
    +    source,
    +    /\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
    +    'extensions array must end with the empty string, not start with it'
    +  )
    +  // The buggy empty-first order must not return.
    +  assert.doesNotMatch(
    +    source,
    +    /\['', \.\.\.\(process\.env\.PATHEXT/,
    +    'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
    +  )
    +})
    +
    +test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
    +  const source = readMain()
    +  assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
    +  assert.match(
    +    source,
    +    /fileExists\(venvPython\)/,
    +    'recovery must accept the venv interpreter as a real-install signal'
    +  )
    +  assert.match(
    +    source,
    +    /\.hermes-bootstrap-complete/,
    +    'recovery must accept the bootstrap-complete marker as a real-install signal'
    +  )
    +  assert.match(
    +    source,
    +    /updaterArgs = haveRealInstall \? \['--update'/,
    +    'updaterArgs must gate on haveRealInstall'
    +  )
    +  // The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
    +  assert.doesNotMatch(
    +    source,
    +    /updaterArgs = fileExists\(venvHermes\) \?/,
    +    'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
    +  )
    +})
    ```
  - `apps/desktop/package.json`

    ```diff
    @@ -37,7 +37,7 @@
         "test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
         "test:desktop:existing": "node scripts/test-desktop.mjs existing",
         "test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
    -    "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",
    +    "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",
         "typecheck": "tsc -p . --noEmit",
         "lint": "eslint src/ electron/",
         "lint:fix": "eslint src/ electron/ --fix",
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -6992,6 +6992,55 @@ def _recover_from_interrupted_install() -> None:
             # the install itself will surface the real problem.
             logger.debug("Could not create install-recovery lock: %s", exc)
     
    +    # Windows self-lock guard: if hermes.exe is the launcher that spawned
    +    # this Python process, any attempt to pip-install will fail with
    +    # "拒绝访问 / WinError 32" because the running .exe cannot be replaced.
    +    # Rather than entering the permanent retry loop described in issue
    +    # #45542, clear the marker and give the user an offline recovery command.
    +    if _is_windows():
    +        scripts_dir = _venv_scripts_dir()
    +        if scripts_dir is not None:
    +            shims = _hermes_exe_shims(scripts_dir)
    +            if shims:
    +                _shim_set: set[str] = set()
    +                for _s in shims:
    +                    try:
    +                        _shim_set.add(str(_s.resolve()).lower())
    +                    except OSError:
    +                        _shim_set.add(str(_s).lower())
    +                try:
    +                    import psutil
    +                    _me = psutil.Process()
    +                    for _anc in [_me] + list(_me.parents()):
    +                        try:
    +                            _anc_exe = _anc.exe()
    +                            _anc_norm = str(Path(_anc_exe).resolve()).lower()
    +                        except Exception:
    +                            continue
    +                        if _anc_norm in _shim_set:
    +                            print(
    +                                "✗ Hermes is running from the binary that "
    +                                "needs to be replaced — the auto-recovery "
    +                                "cannot overwrite a running executable."
    +                            )
    +                            print(
    +                                "  Restart Hermes from a different terminal, "
    +                                "then run the manual recovery command below:"
    +                            )
    +                            print(f'    cd /d "{PROJECT_ROOT}"')
    +                            print(
    +                                f'    "{sys.executable}" -m pip install '
    +                                '-e ".[all]"'
    +                            )
    +                            _clear_update_incomplete_marker()
    +                            try:
    +                                lock_path.unlink()
    +                            except OSError:
    +                                pass
    +                            return
    +                except Exception:
    +                    pass  # psutil is best-effort; fall through to install
    +
         saved_stdout_fd = None
         saved_sys_stdout = sys.stdout
         try:
    ```
  - `scripts/release.py`

    ```diff
    @@ -167,6 +167,8 @@
         "290859878+synapsesx@users.noreply.github.com": "synapsesx",
         "157689911+itsflownium@users.noreply.github.com": "itsflownium",
         "dirtyren@users.noreply.github.com": "dirtyren",
    +    "121278003+Cossackx@users.noreply.github.com": "Cossackx",  # PR #52528 salvage (Windows hermes-shim resolution + prefer --update on recovery; #52378)
    +    "97326386+Icather@users.noreply.github.com": "Icather",  # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542)
         "--email": "[redacted:email]",
         "[redacted:email]": "francescomucio",
         "291572938+thestral123@users.noreply.github.com": "thestral123",
    ```
  - `tests/hermes_cli/test_update_interrupted_recovery.py`

    ```diff
    @@ -139,6 +139,90 @@ class R:
         )
     
     
    +def test_recovery_self_lock_guard_clears_marker_without_install(tmp_path, monkeypatch):
    +    # Windows self-lock: hermes.exe is an ancestor of this Python process, so a
    +    # pip-install would fail trying to replace the running launcher (WinError 32
    +    # / 拒绝访问). Recovery must short-circuit — clear the marker, skip install,
    +    # break the loop (#45542 / #52378) — instead of retrying forever.
    +    monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
    +    (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
    +    m._write_update_incomplete_marker()
    +
    +    scripts_dir = tmp_path / "venv" / "Scripts"
    +    scripts_dir.mkdir(parents=True)
    +    shim = scripts_dir / "hermes.exe"
    +    shim.write_text("")
    +
    +    monkeypatch.setattr(m, "_is_windows", lambda: True)
    +    monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir)
    +    monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim])
    +
    +    class FakeProc:
    +        def __init__(self, exe_path):
    +            self._exe = exe_path
    +
    +        def exe(self):
    +            return self._exe
    +
    +        def parents(self):
    +            return [FakeProc(str(shim))]
    +
    +    monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path()))
    +
    +    seen = {"install": False}
    +    _stub_install_env(monkeypatch, m, seen)
    +
    +    m._recover_from_interrupted_install()
    +
    +    assert seen["install"] is False, "self-lock must skip the install"
    +    assert not m._update_marker_path().exists(), "marker cleared to break the loop"
    +
    +
    +def sys_executable_path():
    +    import sys
    +
    +    return sys.executable
    +
    +
    +def test_recovery_self_lock_guard_inactive_when_not_ancestor(tmp_path, monkeypatch):
    +    # Windows, but hermes.exe is NOT in the ancestry (launched via `hermes
    +    # dashboard` from a separate cmd, say). The guard must fall through to the
    +    # normal install so a genuinely interrupted install still gets healed.
    +    monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
    +    (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
    +    m._write_update_incomplete_marker()
    +
    +    scripts_dir = tmp_path / "venv" / "Scripts"
    +    scripts_dir.mkdir(parents=True)
    +    shim = scripts_dir / "hermes.exe"
    +    shim.write_text("")
    +
    +    monkeypatch.setattr(m, "_is_windows", lambda: True)
    +    monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir)
    +    monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim])
    +
    +    class FakeProc:
    +        def __init__(self, exe_path):
    +            self._exe = exe_path
    +
    +        def exe(self):
    +            return self._exe
    +
    +        def parents(self):
    +            # Ancestry is plain pythons / cmd — no hermes.exe shim.
    +            return [FakeProc(str(tmp_path / "cmd.exe"))]
    +
    +    monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path()))
    +
    +    seen = {"install": False}
    +    _stub_install_env(monkeypatch, m, seen)
    +
    +    m._recover_from_interrupted_install()
    +
    +    assert seen["install"] is True, "without self-lock, normal recovery runs"
    +    assert not m._update_marker_path().exists(), "marker cleared on successful install"
    +
    +
     def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):
         # Another process is mid-recovery (fresh lockfile) — this launch must skip
         # the install entirely and leave both marker and lock untouched.
    ```
- **#45554: fix(cli): detect self-lock in update recovery to break infinite retry…**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#54135`, `#52378`

## Fix status

- Merged to main in PR #54135 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-28`.
- Subsystem: `hermes_cli`
