{"repo":"NousResearch/hermes-agent","public_id":"32384","display_label":"Update/venv rebuild bricking installs, especially Windows","summary":"Multiple bug reports and fix attempts address 'hermes update' corrupting or partially destroying the virtual environment during rebuilds, particularly on Windows, leaving installations broken with missing modules or a damaged git state.","subsystem":"cron/scripts","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33803},"resolution":{"state":"partially_resolved","by":"pr-33803"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T13:05:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14727272727272725},"fix_edges":[{"issue_number":33788,"pr_number":33803,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37881,"pr_number":38887,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":32384,"title":"[Bug]: `hermes update` Corrupts Git Repo and Breaks Installation","url":null,"state":"closed"},{"kind":"issue","number":33788,"title":"[Bug]: Update got stuck half way, reboot it, then ModuleNotFoundError: No module named 'hermes_cli'","url":null,"state":"closed"},{"kind":"issue","number":37881,"title":"[Bug]: `hermes update` bricks the install on Windows — venv rebuild leaves a venv with no `pyvenv.cfg`, then   `ModuleNotFoundError: hermes_cli`","url":null,"state":"closed"},{"kind":"pr","number":32438,"title":"fix(install): auto-recover install.sh update path from stale remote-tracking refs (#32384)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":33803,"title":"fix(update): stream + idle-kill webui-build so a stall can't soft-brick the install (#33788)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_cmd_update.py","tests/hermes_cli/test_run_with_idle_timeout.py","tests/hermes_cli/test_web_ui_build.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -6509,6 +6509,104 @@ def _web_ui_build_needed(web_dir: Path) -> bool:\n     return False\n \n \n+def _run_with_idle_timeout(\n+    cmd: list[str],\n+    cwd: Path,\n+    *,\n+    idle_timeout_seconds: int = 180,\n+    indent: str = \"    \",\n+) -> subprocess.CompletedProcess:\n+    \"\"\"Run a subprocess that streams output, with an idle-output timeout.\n+\n+    Issue #33788: ``npm run build`` (Vite) was invoked with\n+    ``capture_output=True`` and no timeout. On low-memory hosts (notably\n+    WSL2 with the default 4 GB cap) the build can stall or sit silent for\n+    minutes; users see a frozen terminal, assume the update is hung, and\n+    reboot — leaving the editable install in a half-state with the\n+    ``hermes`` launcher present but ``hermes_cli`` not importable.\n+\n+    This helper fixes both halves: stdout is streamed (so the user sees\n+    progress), and if no bytes have appeared on stdout/stderr for\n+    ``idle_timeout_seconds``, the process is terminated and the call\n+    returns with a non-zero ``returncode``. The caller's existing\n+    stale-dist fallback (#23817) takes over from there.\n+\n+    Returns a ``CompletedProcess`` with merged stdout (text), empty\n+    stderr, and an integer returncode. Never raises on idle timeout —\n+    propagation of failure is via the returncode.\n+    \"\"\"\n+    merged_chunks: list[str] = []\n+    last_output_ts = _time.monotonic()\n+    lock = threading.Lock()\n+\n+    try:\n+        proc = subprocess.Popen(\n+            cmd,\n+            cwd=cwd,\n+            stdout=subprocess.PIPE,\n+            stderr=subprocess.STDOUT,\n+            text=True,\n+            encoding=\"utf-8\",\n+            errors=\"replace\",\n+            bufsize=1,\n+        )\n+    except OSError as exc:\n+        # E.g. npm not on PATH between the which() check and now.\n+        return subprocess.CompletedProcess(cmd, 127, stdout=\"\", stderr=str(exc))\n+\n+    def _reader() -> None:\n+        nonlocal last_output_ts\n+        assert proc.stdout is not None\n+        for line in proc.stdout:\n+            try:\n+                print(f\"{indent}{line.rstrip()}\", flush=True)\n+            except UnicodeEncodeError:\n+                # Windows cp1252 fallback — same pattern as _say().\n+                enc = getattr(sys.stdout, \"encoding\", None) or \"ascii\"\n+                safe = line.rstrip().encode(enc, errors=\"replace\").decode(enc, errors=\"replace\")\n+                print(f\"{indent}{safe}\", flush=True)\n+            with lock:\n+                merged_chunks.append(line)\n+                last_output_ts = _time.monotonic()\n+\n+    reader_thread = threading.Thread(target=_reader, daemon=True)\n+    reader_thread.start()\n+\n+    idle_killed = False\n+    while True:\n+        try:\n+            rc = proc.wait(timeout=5)\n+            break\n+        except subprocess.TimeoutExpired:\n+            with lock:\n+                idle = _time.monotonic() - last_output_ts\n+            if idle > idle_timeout_seconds:\n+                idle_killed = True\n+                proc.terminate()\n+                try:\n+                    rc = proc.wait(timeout=3)\n+                except subprocess.TimeoutExpired:\n+                    proc.kill()\n+                    rc = proc.wait()\n+                break\n+\n+    # Drain reader so we don't leak the stdout file descriptor.\n+    reader_thread.join(timeout=2)\n+\n+    combined = \"\".join(merged_chunks)\n+    if idle_killed:\n+        msg = (\n+            f\"\\n  ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\\n\"\n+            \"    Common causes: out-of-memory on a low-RAM host (WSL/container),\\n\"\n+            \"    a stuck Node process, or an antivirus scan stalling I/O.\\n\"\n+        )\n+        combined += msg\n+        # Force a non-zero rc even if terminate() raced with a clean exit.\n+        if rc == 0:\n+            rc = 124  # GNU `timeout` convention\n+    return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr=\"\")\n+\n+\n def _run_npm_install_deterministic(\n     npm: str,\n     cwd: Path,","@@ -6614,31 +6712,26 @@ def _relay(result: \"subprocess.CompletedProcess\") -> None:\n         if fatal:\n             _say(\"  Run manually:  cd web && npm install && npm run build\")\n         return False\n-    # First attempt\n-    r2 = subprocess.run(\n-        [npm, \"run\", \"build\"],\n-        cwd=web_dir,\n-        capture_output=True,\n-        text=True,\n-        encoding=\"utf-8\",\n-        errors=\"replace\",\n-    )\n+    # First attempt — stream output via idle-timeout helper (issue #33788).\n+    # capture_output=True on a long Vite build looks identical to a hang;\n+    # users react by rebooting, which leaves the editable install in a\n+    # half-state. Streaming + idle-kill makes failures observable AND\n+    # recoverable (the stale-dist fallback below handles the kill path).\n+    r2 = _run_with_idle_timeout([npm, \"run\", \"build\"], cwd=web_dir)\n     if r2.returncode != 0:\n         # Retry once after a short delay — covers boot-time races on Windows\n         # (antivirus scanning Node.js binaries, npm cache not ready, transient\n         # I/O when launched via Scheduled Task at logon). See issue #23817.\n         _time.sleep(3)\n-        r2 = subprocess.run(\n-            [npm, \"run\", \"build\"],\n-            cwd=web_dir,\n-            capture_output=True,\n-            text=True,\n-            encoding=\"utf-8\",\n-            errors=\"replace\",\n-        )\n+        r2 = _run_with_idle_timeout([npm, \"run\", \"build\"], cwd=web_dir)\n \n     if r2.returncode != 0:\n-        stderr_preview = (r2.stderr or \"\").strip()\n+        # _run_with_idle_timeout merges stderr into stdout; older callers\n+        # using subprocess.run kept them split. Pull from whichever has\n+        # content so the error surfaces regardless of which path produced\n+        # the CompletedProcess.\n+        build_output = (r2.stderr or \"\") + (r2.stdout or \"\")\n+        stderr_preview = build_output.strip()\n         stderr_tail = \"\\n  \".join(stderr_preview.splitlines()[-10:]) if stderr_preview else \"\"\n         dist_dir = web_dir.parent / \"hermes_cli\" / \"web_dist\"\n         dist_index = dist_dir / \"index.html\"","@@ -7129,6 +7222,11 @@ def _update_via_zip(args):\n         _install_python_dependencies_with_optional_fallback(pip_cmd)\n \n     _update_node_dependencies()\n+    # Core (Python deps + git pull / ZIP extract) is now complete; the CLI\n+    # is functional from this point onward. The web UI build below is\n+    # optional — a failure here only affects ``hermes dashboard``. Make\n+    # that visible so users don't panic and reboot mid-build (#33788).\n+    print(\"→ Core update complete. Building dashboard (optional)...\")\n     _build_web_ui(PROJECT_ROOT / \"web\")\n \n     # Sync skills","@@ -9203,6 +9301,10 @@ def _cmd_update_impl(args, gateway_mode: bool):\n         _refresh_active_lazy_features()\n \n         _update_node_dependencies()\n+        # See note above (ZIP path): core is now complete, web UI build is\n+        # optional from a CLI perspective. Telegraphing this avoids the\n+        # \"stuck at webui-build → reboot → broken install\" trap (#33788).\n+        print(\"→ Core update complete. Building dashboard (optional)...\")\n         _build_web_ui(PROJECT_ROOT / \"web\")\n \n         print()"]},{"file":"tests/hermes_cli/test_cmd_update.py","hunks":["@@ -144,7 +144,13 @@ def test_update_refreshes_repo_and_tui_node_dependencies(\n         mock_run.side_effect = _make_run_side_effect(\n             branch=\"main\", verify_ok=True, commit_count=\"1\"\n         )\n-        with patch.object(hm, \"_is_termux_env\", return_value=False):\n+        # The web UI build runs through _run_with_idle_timeout now (issue\n+        # #33788) so it no longer appears in subprocess.run's call list.\n+        # Mock it so the test doesn't actually shell out to ``tsc``.\n+        import subprocess as _subprocess\n+        build_ok = _subprocess.CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        with patch.object(hm, \"_is_termux_env\", return_value=False), \\\n+             patch.object(hm, \"_run_with_idle_timeout\", return_value=build_ok) as mock_idle:\n             cmd_update(mock_args)\n \n         npm_calls = [","@@ -153,10 +159,11 @@ def test_update_refreshes_repo_and_tui_node_dependencies(\n             if call.args and call.args[0][0] == \"/usr/bin/npm\"\n         ]\n \n-        # cmd_update runs npm commands in three locations:\n-        #   1. repo root  — slash-command / TUI bridge deps\n-        #   2. ui-tui/    — Ink TUI deps\n-        #   3. web/       — install + \"npm run build\" for the web frontend\n+        # cmd_update runs npm commands in four locations:\n+        #   1. repo root  — slash-command / TUI bridge deps  (subprocess.run)\n+        #   2. ui-tui/    — Ink TUI deps                     (subprocess.run)\n+        #   3. web/       — npm install                      (subprocess.run)\n+        #   4. web/       — npm run build                    (_run_with_idle_timeout)\n         #\n         # Repo-root and ui-tui installs intentionally omit `--silent` and run\n         # without `capture_output` so optional postinstall scripts (e.g.","@@ -175,11 +182,18 @@ def test_update_refreshes_repo_and_tui_node_dependencies(\n             (update_flags, PROJECT_ROOT / \"ui-tui\"),\n         ]\n         if len(npm_calls) > 2:\n+            # Only the web/ install is left in subprocess.run; the build moved\n+            # to _run_with_idle_timeout to make Vite progress visible (#33788).\n             assert npm_calls[2:] == [\n                 ([\"/usr/bin/npm\", \"ci\", \"--silent\"], PROJECT_ROOT / \"web\"),\n-                ([\"/usr/bin/npm\", \"run\", \"build\"], PROJECT_ROOT / \"web\"),\n             ]\n \n+        # The web UI build itself went through the streaming helper.\n+        mock_idle.assert_called_once()\n+        idle_args, idle_kwargs = mock_idle.call_args\n+        assert idle_args[0] == [\"/usr/bin/npm\", \"run\", \"build\"]\n+        assert idle_kwargs[\"cwd\"] == PROJECT_ROOT / \"web\"\n+\n         # Regression for #18840: repo root + ui-tui installs must stream\n         # output (capture_output=False) so postinstall progress is visible\n         # to the user."]},{"file":"tests/hermes_cli/test_run_with_idle_timeout.py","hunks":["@@ -0,0 +1,67 @@\n+\"\"\"Coverage for _run_with_idle_timeout — the streaming subprocess helper.\n+\n+Kept in a dedicated test file because the tests spawn real ``subprocess.Popen``\n+instances; pytest-isolate runs each test file in its own worker process, so\n+isolating these here prevents real-Popen state from racing with the\n+``subprocess.run`` / ``_run_with_idle_timeout`` patches used by\n+``test_web_ui_build.py``.\n+\n+Added for issue #33788: ``hermes update`` got stuck at \"webui-build\" because\n+``npm run build`` ran with ``capture_output=True`` and no timeout. The helper\n+fixes both halves — streams output AND idle-kills the process.\n+\"\"\"\n+\n+import sys as _sys\n+import time\n+\n+from hermes_cli.main import _run_with_idle_timeout\n+\n+\n+def test_streams_output_and_returns_zero_on_success(tmp_path):\n+    script = tmp_path / \"ok.py\"\n+    script.write_text(\"print('line one'); print('line two')\\n\")\n+    result = _run_with_idle_timeout(\n+        [_sys.executable, str(script)], cwd=tmp_path, idle_timeout_seconds=10\n+    )\n+    assert result.returncode == 0\n+    assert \"line one\" in result.stdout\n+    assert \"line two\" in result.stdout\n+\n+\n+def test_propagates_nonzero_exit(tmp_path):\n+    script = tmp_path / \"fail.py\"\n+    script.write_text(\"import sys; print('boom', file=sys.stderr); sys.exit(7)\\n\")\n+    result = _run_with_idle_timeout(\n+        [_sys.executable, str(script)], cwd=tmp_path, idle_timeout_seconds=10\n+    )\n+    assert result.returncode == 7\n+    # stderr is merged into stdout in the helper.\n+    assert \"boom\" in result.stdout\n+\n+\n+def test_kills_process_on_idle_timeout(tmp_path):\n+    # Sleeps without printing — exactly the failure mode users see when\n+    # `npm run build` stalls. Idle timeout must terminate it.\n+    script = tmp_path / \"stall.py\"\n+    script.write_text(\"import time; time.sleep(30)\\n\")\n+\n+    start = time.monotonic()\n+    result = _run_with_idle_timeout(\n+        [_sys.executable, str(script)],\n+        cwd=tmp_path,\n+        idle_timeout_seconds=1,\n+    )\n+    elapsed = time.monotonic() - start\n+    # Should have died well before the 30s sleep completes.\n+    assert elapsed < 15\n+    assert result.returncode != 0\n+    assert \"produced no output\" in result.stdout\n+\n+\n+def test_returns_127_when_binary_missing(tmp_path):\n+    result = _run_with_idle_timeout(\n+        [\"/nonexistent/binary/does/not/exist\"],\n+        cwd=tmp_path,\n+        idle_timeout_seconds=5,\n+    )\n+    assert result.returncode == 127"]},{"file":"tests/hermes_cli/test_web_ui_build.py","hunks":["@@ -113,12 +113,17 @@ def test_runs_npm_when_dist_missing(self, tmp_path):\n         web_dir, _ = _make_web_dir(tmp_path)\n \n         mock_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=b\"\", stderr=b\"\")\n+        build_ok = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n         with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n-             patch(\"hermes_cli.main.subprocess.run\", return_value=mock_cp) as mock_run:\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=mock_cp) as mock_run, \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\", return_value=build_ok) as mock_idle:\n             result = _build_web_ui(web_dir)\n \n         assert result is True\n-        assert mock_run.call_count == 2  # npm install + npm run build\n+        # npm install goes through subprocess.run; npm run build goes through\n+        # _run_with_idle_timeout (issue #33788).\n+        assert mock_run.call_count == 1   # install only\n+        assert mock_idle.call_count == 1  # build only\n \n     def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):\n         web_dir, _ = _make_web_dir(tmp_path)","@@ -134,19 +139,29 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):\n         assert kwargs[\"encoding\"] == \"utf-8\"\n         assert kwargs[\"errors\"] == \"replace\"\n \n-    def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path):\n+    def test_web_build_uses_idle_timeout_helper(self, tmp_path):\n+        \"\"\"npm run build now goes through _run_with_idle_timeout (issue #33788).\n+\n+        The install step keeps its capture_output behavior (the existing\n+        retry-on-EPERM contract depends on it); only the long-running build\n+        step is streamed + idle-killed.\n+        \"\"\"\n         web_dir, _ = _make_web_dir(tmp_path)\n \n-        mock_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        install_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        build_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n         with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n-             patch(\"hermes_cli.main.subprocess.run\", side_effect=[mock_cp, mock_cp]) as mock_run:\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=install_cp), \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\", return_value=build_cp) as mock_idle:\n             result = _build_web_ui(web_dir)\n \n         assert result is True\n-        _, build_kwargs = mock_run.call_args_list[1]\n-        assert build_kwargs[\"text\"] is True\n-        assert build_kwargs[\"encoding\"] == \"utf-8\"\n-        assert build_kwargs[\"errors\"] == \"replace\"\n+        # Build was invoked through the idle-timeout helper, not subprocess.run.\n+        mock_idle.assert_called_once()\n+        args, kwargs = mock_idle.call_args\n+        # Positional: [npm, \"run\", \"build\"]; cwd passed as kwarg.\n+        assert args[0] == [\"/usr/bin/npm\", \"run\", \"build\"]\n+        assert kwargs[\"cwd\"] == web_dir\n \n \n class TestBuildWebUIRetryAndStaleFallback:","@@ -155,18 +170,19 @@ class TestBuildWebUIRetryAndStaleFallback:\n     def test_retries_build_once_on_failure(self, tmp_path):\n         web_dir, _ = _make_web_dir(tmp_path)\n         Subprocess = __import__(\"subprocess\")\n-        # install: success; build attempt 1: fail; build attempt 2: success\n         install_ok = Subprocess.CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n-        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"\", stderr=\"EPERM\")\n+        # build attempt 1: fail; build attempt 2: success.\n+        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"EPERM\", stderr=\"\")\n         build_ok = Subprocess.CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n         with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n              patch(\"hermes_cli.main._time.sleep\") as mock_sleep, \\\n-             patch(\"hermes_cli.main.subprocess.run\",\n-                   side_effect=[install_ok, build_fail, build_ok]) as mock_run:\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=install_ok), \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\",\n+                   side_effect=[build_fail, build_ok]) as mock_idle:\n             result = _build_web_ui(web_dir)\n \n         assert result is True\n-        assert mock_run.call_count == 3  # install + build + retry\n+        assert mock_idle.call_count == 2  # build + retry\n         mock_sleep.assert_called_once_with(3)\n \n     def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):","@@ -177,11 +193,12 @@ def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):\n \n         Subprocess = __import__(\"subprocess\")\n         install_ok = Subprocess.CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n-        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"\", stderr=\"vite ENOMEM\")\n+        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"vite ENOMEM\", stderr=\"\")\n         with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n              patch(\"hermes_cli.main._time.sleep\"), \\\n-             patch(\"hermes_cli.main.subprocess.run\",\n-                   side_effect=[install_ok, build_fail, build_fail]):\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=install_ok), \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\",\n+                   side_effect=[build_fail, build_fail]):\n             result = _build_web_ui(web_dir, fatal=True)\n \n         # MUST return True (serve stale) — issue #23817 — even with fatal=True,","@@ -189,18 +206,19 @@ def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):\n         assert result is True\n         out = capsys.readouterr().out\n         assert \"serving stale dist as fallback\" in out\n-        assert \"vite ENOMEM\" in out  # stderr surfaced to user\n+        assert \"vite ENOMEM\" in out  # combined output surfaced to user\n \n     def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys):\n         web_dir, _ = _make_web_dir(tmp_path)\n \n         Subprocess = __import__(\"subprocess\")\n         install_ok = Subprocess.CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n-        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"\", stderr=\"vite ENOMEM\")\n+        build_fail = Subprocess.CompletedProcess([], 1, stdout=\"vite ENOMEM\", stderr=\"\")\n         with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n              patch(\"hermes_cli.main._time.sleep\"), \\\n-             patch(\"hermes_cli.main.subprocess.run\",\n-                   side_effect=[install_ok, build_fail, build_fail]):\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=install_ok), \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\",\n+                   side_effect=[build_fail, build_fail]):\n             result = _build_web_ui(web_dir, fatal=True)\n \n         assert result is False\n"]}]},{"kind":"pr","number":37895,"title":"fix(cli): pass --clear to uv venv in rebuild_venv to avoid Windows brick","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c136eb4de","number":0},{"kind":"pr","value":"#37881","number":37881}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37965,"title":"fix(update): add --clear flag to uv venv rebuild to prevent Windows bricking #37881","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38051,"title":"fix(config): add --clear flag to uv venv rebuild to prevent Windows bricking #37881","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c136eb4de","number":0},{"kind":"pr","value":"#37881","number":37881}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38397,"title":"fix(uv): never gut the venv in place when rebuilding on Windows","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38887","number":38887}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38797,"title":"fix(cli): use os.replace to safely rebuild venv when hermes.exe is running (#37881)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38887","number":38887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38887,"title":"fix(update): harden venv rebuild + verify core deps after install","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/managed_uv.py","tests/hermes_cli/test_managed_uv.py","tests/hermes_cli/test_verify_core_dependencies.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -9023,6 +9023,222 @@ def _install(args: list[str]) -> None:\n             f\"  ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}\"\n         )\n \n+    # Belt-and-suspenders: verify every declared core dependency from\n+    # pyproject.toml's [project.dependencies] is actually importable in the\n+    # target venv. uv's incremental resolver has — in the wild — produced\n+    # partial installs where a newly added base dep (e.g. ``pathspec``)\n+    # silently fails to land on top of a half-stale venv, and the only\n+    # symptom is a downstream subprocess crashing with ModuleNotFoundError\n+    # hours later inside ``hermes update``'s desktop-rebuild or skill-sync\n+    # stage. Reinstall with --reinstall to force resolution if anything is\n+    # missing, then re-verify so the failure surfaces here instead of\n+    # downstream.\n+    _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)\n+\n+\n+def _verify_core_dependencies_installed(\n+    install_cmd_prefix: list[str],\n+    *,\n+    env: dict[str, str] | None = None,\n+    group: str = \"all\",\n+) -> None:\n+    \"\"\"Check that every base dep from pyproject.toml is importable; if not, retry.\n+\n+    Reads ``pyproject.toml`` directly (so we don't trust the venv's stale\n+    metadata), filters out deps gated by ``;`` environment markers that don't\n+    apply to this platform, and runs ``importlib.metadata.version()`` in the\n+    venv interpreter for each one. If anything is missing we reinstall the\n+    base group with ``--reinstall`` to force uv to re-resolve, then check\n+    again. We treat the final state as a warning rather than a hard failure\n+    so a single broken-on-PyPI dep can't block an otherwise-successful\n+    update — but the warning makes the partial install visible at the spot\n+    that caused it, instead of hours later in a downstream subprocess.\n+    \"\"\"\n+    try:\n+        import tomllib  # Python 3.11+\n+    except ImportError:  # pragma: no cover — Python < 3.11 unsupported but be safe\n+        return\n+\n+    pyproject = PROJECT_ROOT / \"pyproject.toml\"\n+    if not pyproject.is_file():\n+        return\n+\n+    try:\n+        with open(pyproject, \"rb\") as f:\n+            data = tomllib.load(f)\n+        raw_deps = data.get(\"project\", {}).get(\"dependencies\", []) or []\n+    except Exception as e:\n+        logger.debug(\"dep verification: failed to read pyproject.toml: %s\", e)\n+        return\n+\n+    # Parse each \"name OP version ; marker\" string into (dist_name, marker_obj).\n+    # We use packaging.requirements when available (it ships with pip/uv envs),\n+    # falling back to a naive split that's good enough for the canonical\n+    # ``name==version[; marker]`` style this repo uses.\n+    deps: list[tuple[str, \"object | None\"]] = []\n+    try:\n+        from packaging.requirements import Requirement  # type: ignore\n+\n+        for spec in raw_deps:\n+            try:\n+                req = Requirement(spec)\n+                deps.append((req.name, req.marker))\n+            except Exception:\n+                continue\n+    except Exception:\n+        for spec in raw_deps:\n+            head = spec.split(\";\", 1)[0]\n+            for op in (\"==\", \">=\", \"<=\", \"~=\", \">\", \"<\", \"!=\"):\n+                if op in head:\n+                    head = head.split(op, 1)[0]\n+                    break\n+            name = head.strip().split(\"[\", 1)[0].strip()\n+            if name:\n+                deps.append((name, None))\n+\n+    # Apply environment markers to drop deps that don't apply on this platform\n+    # (e.g. ``ptyprocess ; sys_platform != 'win32'`` is correctly skipped on\n+    # Windows). Without markers we'd false-positive every cross-platform exclusion.\n+    applicable: list[str] = []\n+    for name, marker in deps:\n+        if marker is None:\n+            applicable.append(name)\n+            continue\n+        try:\n+            if marker.evaluate():  # type: ignore[union-attr]\n+                applicable.append(name)\n+        except Exception:\n+            applicable.append(name)\n+\n+    if not applicable:\n+        return\n+\n+    # Run the check inside the venv Python — sys.executable here may be the\n+    # outer Python that drove ``hermes update``, not the venv we just wrote\n+    # to. The uv install_cmd_prefix encodes which environment we targeted\n+    # (either ``[uv, pip]`` with VIRTUAL_ENV in env, or\n+    # ``[sys.executable, -m, pip]`` for the in-process Python); resolve the\n+    # right interpreter for the verification.\n+    venv_python = _resolve_install_target_python(install_cmd_prefix, env)\n+    if venv_python is None:\n+        return\n+\n+    def _missing_deps() -> list[str]:\n+        check_script = (\n+            \"import importlib.metadata as md, sys\\n\"\n+            \"missing=[]\\n\"\n+            \"for name in sys.argv[1:]:\\n\"\n+            \"    try: md.version(name)\\n\"\n+            \"    except md.PackageNotFoundError: missing.append(name)\\n\"\n+            \"print('\\\\n'.join(missing))\\n\"\n+        )\n+        try:\n+            result = subprocess.run(\n+                [str(venv_python), \"-c\", check_script, *applicable],\n+                capture_output=True,\n+                text=True,\n+                check=False,\n+                env=env,\n+            )\n+        except Exception as e:\n+            logger.debug(\"dep verification: subprocess failed: %s\", e)\n+            return []\n+        return [line.strip() for line in result.stdout.splitlines() if line.strip()]\n+\n+    missing = _missing_deps()\n+    if not missing:\n+        return\n+\n+    print(\n+        f\"  ⚠ Verification: {len(missing)} declared dep(s) missing after install: \"\n+        f\"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}\"\n+    )\n+    print(\"  → Reinstalling base group with --reinstall to repair...\")\n+\n+    # Reinstall base group with --reinstall so uv re-resolves from scratch\n+    # against the current pyproject. We don't pass ``[{group}]`` here on\n+    # purpose — the missing dep is in *base* deps; rerunning the full all-\n+    # extras install can cost minutes and trips on whatever optional extra\n+    # was already broken upstream. Base is fast and is what's actually wrong.\n+    repair_args = [\"install\", \"--reinstall\", \"-e\", \".\"]\n+    try:\n+        _run_install_with_heartbeat(install_cmd_prefix + repair_args, env=env)\n+    except subprocess.CalledProcessError as e:\n+        logger.warning(\"dep verification: repair install failed: %s\", e)\n+        print(\"  ⚠ Repair install failed; check `hermes update` output above.\")\n+        return\n+\n+    still_missing = _missing_deps()\n+    if not still_missing:\n+        print(\"  ✓ All declared core dependencies now installed\")\n+        return\n+\n+    # Last-ditch: install each remaining missing dep with its pin directly.\n+    # Useful when uv's resolver thinks the env is satisfied but the on-disk\n+    # package metadata says otherwise (rare but observed).\n+    name_to_spec = {}\n+    for spec in raw_deps:\n+        head = spec.split(\";\", 1)[0].strip()\n+        bare = head\n+        for op in (\"==\", \">=\", \"<=\", \"~=\", \">\", \"<\", \"!=\"):\n+            if op in bare:\n+                bare = bare.split(op, 1)[0]\n+                break\n+        name_to_spec[bare.strip().split(\"[\", 1)[0].strip()] = head\n+\n+    specs = [name_to_spec.get(n, n) for n in still_missing]\n+    print(\n+        f\"  → Force-installing remaining missing dep(s): {', '.join(specs)}\"\n+    )\n+    try:\n+        _run_install_with_heartbeat(\n+            install_cmd_prefix + [\"install\", \"--reinstall\", *specs], env=env\n+        )\n+    except subprocess.CalledProcessError as e:\n+        logger.warning(\"dep verification: per-package repair failed: %s\", e)\n+        print(\n+            f\"  ⚠ Could not install: {', '.join(still_missing)}. \"\n+            \"Run `hermes update --force` after closing other hermes processes.\"\n+        )\n+        return\n+\n+    final_missing = _missing_deps()\n+    if final_missing:\n+        print(\n+            f\"  ⚠ Still missing after repair: {', '.join(final_missing)}. \"\n+            \"Run `hermes update --force` after closing other hermes processes.\"\n+        )\n+    else:\n+        print(\"  ✓ All declared core dependencies now installed\")\n+\n+\n+def _resolve_install_target_python(\n+    install_cmd_prefix: list[str], env: dict[str, str] | None\n+) -> Path | None:\n+    \"\"\"Figure out which Python interpreter the install just targeted.\n+\n+    ``_install_python_dependencies_with_optional_fallback`` is called with\n+    either ``[uv, pip]`` (and a ``VIRTUAL_ENV`` env var pointing at the\n+    target venv) or ``[sys.executable, -m, pip]`` (the in-process Python).\n+    The verification step needs the *resulting* environment's Python so\n+    ``importlib.metadata`` queries the right site-packages.\n+    \"\"\"\n+    if env and \"VIRTUAL_ENV\" in env:\n+        venv_root = Path(env[\"VIRTUAL_ENV\"])\n+        scripts = venv_root / (\"Scripts\" if _is_windows() else \"bin\")\n+        candidate = scripts / (\"python.exe\" if _is_windows() else \"python\")\n+        if candidate.exists():\n+            return candidate\n+\n+    # Fallback: assume install_cmd_prefix[0] is the python interpreter (the\n+    # ``[sys.executable, -m, pip]`` shape). Skip if it looks like ``uv``.\n+    if install_cmd_prefix:\n+        first = Path(install_cmd_prefix[0])\n+        if first.exists() and \"uv\" not in first.name.lower():\n+            return first\n+\n+    return None\n+\n \n def _is_termux_env(env: dict[str, str] | None = None) -> bool:\n     return _is_termux_startup_environment(env)"]},{"file":"hermes_cli/managed_uv.py","hunks":["@@ -105,17 +105,40 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = \"3.11\") -> b\n     old venv may point to a Python without FTS5, so we rebuild it with a\n     fresh interpreter from the current managed uv.  Returns ``True`` on\n     success.\n+\n+    On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave\n+    the venv directory partially intact when another process is holding an\n+    open handle to a file inside it (typical culprits: a running\n+    ``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that\n+    and just call ``uv venv``, uv refuses with\n+    ``Caused by: A directory already exists at: venv`` and the *whole\n+    update* falls back to installing on top of the stale venv — which has\n+    historically produced partial installs where a freshly added dependency\n+    (e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to\n+    force uv past that condition before giving up.\n     \"\"\"\n     if venv_dir.exists():\n         print(f\"  → Rebuilding venv (old Python may lack FTS5)...\")\n         shutil.rmtree(venv_dir, ignore_errors=True)\n \n-    result = subprocess.run(\n-        [uv_bin, \"venv\", str(venv_dir), \"--python\", python_version],\n-        capture_output=True,\n-        text=True,\n-        check=False,\n-    )\n+    def _run_uv_venv(extra_args: list[str]) -> subprocess.CompletedProcess[str]:\n+        return subprocess.run(\n+            [uv_bin, \"venv\", str(venv_dir), \"--python\", python_version, *extra_args],\n+            capture_output=True,\n+            text=True,\n+            check=False,\n+        )\n+\n+    result = _run_uv_venv([])\n+\n+    # If uv refused because the directory still exists (rmtree above was\n+    # blocked by an open file handle, common on Windows), retry with\n+    # --clear so uv overwrites it. Match on stderr because uv's exit code\n+    # alone doesn't distinguish \"dir exists\" from real failures.\n+    if result.returncode != 0 and \"already exists\" in (result.stderr or \"\").lower():\n+        print(\"  → venv dir not fully removed (likely an open file handle); retrying with --clear...\")\n+        result = _run_uv_venv([\"--clear\"])\n+\n     if result.returncode == 0:\n         venv_python = venv_dir / (\"Scripts\" if platform.system() == \"Windows\" else \"bin\") / \"python\"\n         py_ver = subprocess.run("]},{"file":"tests/hermes_cli/test_managed_uv.py","hunks":["@@ -145,6 +145,85 @@ def test_rebuild_failure_returns_false(self, tmp_path):\n             result = rebuild_venv(uv_bin, venv_dir)\n             assert result is False\n \n+    def test_retries_with_clear_when_dir_already_exists(self, tmp_path):\n+        \"\"\"On Windows, rmtree can silently fail when an open handle holds a\n+        file in the venv (running hermes.exe, gateway, AV scanner). uv then\n+        refuses with ``Caused by: A directory already exists at: venv``.\n+        Make sure we don't give up — retry with ``--clear`` to force uv past\n+        the stale directory and rebuild successfully.\"\"\"\n+        venv_dir = tmp_path / \"venv\"\n+        venv_dir.mkdir()\n+        (venv_dir / \"stale_open_handle\").write_text(\"rmtree couldn't delete me\")\n+\n+        uv_bin = str(tmp_path / \"bin\" / \"uv\")\n+        call_log: list[list[str]] = []\n+\n+        def fake_run(cmd, **kwargs):\n+            call_log.append(list(cmd))\n+            m = MagicMock()\n+            if cmd[1] == \"venv\" and \"--clear\" not in cmd:\n+                # First attempt: uv refuses because dir still exists\n+                m.returncode = 1\n+                m.stderr = (\n+                    \"error: Failed to create virtual environment\\n\"\n+                    \"  Caused by: A directory already exists at: venv\\n\"\n+                    \"hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\\n\"\n+                )\n+                m.stdout = \"\"\n+                return m\n+            if cmd[1] == \"venv\" and \"--clear\" in cmd:\n+                # Retry: succeeds. Simulate uv writing the python shim.\n+                m.returncode = 0\n+                m.stderr = \"\"\n+                m.stdout = \"\"\n+                bin_dir = venv_dir / (\"Scripts\" if os.name == \"nt\" else \"bin\")\n+                bin_dir.mkdir(parents=True, exist_ok=True)\n+                python_name = \"python.exe\" if os.name == \"nt\" else \"python\"\n+                (bin_dir / python_name).write_text(\"#!/bin/sh\\necho Python 3.11.0\")\n+                return m\n+            if \"--version\" in cmd:\n+                m.returncode = 0\n+                m.stdout = \"Python 3.11.0\"\n+                m.stderr = \"\"\n+                return m\n+            m.returncode = 0\n+            return m\n+\n+        with patch(\"hermes_cli.managed_uv.subprocess.run\", side_effect=fake_run), \\\n+             patch(\"hermes_cli.managed_uv.shutil.rmtree\"):\n+            from hermes_cli.managed_uv import rebuild_venv\n+            result = rebuild_venv(uv_bin, venv_dir)\n+\n+        assert result is True, \"rebuild should succeed after --clear retry\"\n+        # We expect exactly two ``uv venv`` calls: one without --clear, one with.\n+        venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == \"venv\"]\n+        assert len(venv_calls) == 2, f\"expected 2 venv calls, got {venv_calls}\"\n+        assert \"--clear\" not in venv_calls[0], \"first call should not pass --clear\"\n+        assert \"--clear\" in venv_calls[1], \"retry must pass --clear\"\n+\n+    def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path):\n+        \"\"\"If uv venv fails for some other reason (e.g. interpreter download\n+        failed, disk full), we should NOT silently retry with --clear —\n+        that would mask a real problem. Just surface the original failure.\"\"\"\n+        venv_dir = tmp_path / \"venv\"\n+        uv_bin = str(tmp_path / \"bin\" / \"uv\")\n+        call_log: list[list[str]] = []\n+\n+        def fake_run(cmd, **kwargs):\n+            call_log.append(list(cmd))\n+            m = MagicMock(returncode=1, stderr=\"error: No space left on device\", stdout=\"\")\n+            return m\n+\n+        with patch(\"hermes_cli.managed_uv.subprocess.run\", side_effect=fake_run), \\\n+             patch(\"hermes_cli.managed_uv.shutil.rmtree\"):\n+            from hermes_cli.managed_uv import rebuild_venv\n+            result = rebuild_venv(uv_bin, venv_dir)\n+\n+        assert result is False\n+        venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == \"venv\"]\n+        assert len(venv_calls) == 1, \"should not retry on non-dir-exists failures\"\n+        assert \"--clear\" not in venv_calls[0]\n+\n \n # ---------------------------------------------------------------------------\n # update_managed_uv"]},{"file":"tests/hermes_cli/test_verify_core_dependencies.py","hunks":["@@ -0,0 +1,223 @@\n+\"\"\"Tests for _verify_core_dependencies_installed.\n+\n+Regression coverage for the partial-install bug where uv's incremental\n+resolver silently failed to land ``pathspec`` (and similar newly-added\n+base deps) during ``hermes update``, leaving the venv in a broken state\n+that only surfaced hours later when a downstream subprocess imported the\n+missing module.\n+\n+The verification step:\n+  1. Reads pyproject.toml's [project.dependencies] directly.\n+  2. Filters by environment markers so cross-platform exclusions don't\n+     false-positive (e.g. ``ptyprocess ; sys_platform != 'win32'`` on Windows).\n+  3. Probes ``importlib.metadata.version()`` in the venv interpreter.\n+  4. Reinstalls with --reinstall, then per-package, if anything's missing.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import subprocess\n+import textwrap\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n[redacted:email]\n+def temp_pyproject(tmp_path, monkeypatch):\n+    \"\"\"Point hermes_cli.main.PROJECT_ROOT at a tmp dir with a minimal pyproject.\n+\n+    The verification helper opens ``PROJECT_ROOT / 'pyproject.toml'`` directly;\n+    redirecting PROJECT_ROOT keeps the test hermetic.\n+    \"\"\"\n+    pyproject = tmp_path / \"pyproject.toml\"\n+    pyproject.write_text(textwrap.dedent(\"\"\"\\\n+        [project]\n+        name = \"fake\"\n+        version = \"0.0.0\"\n+        dependencies = [\n+          \"pathspec==1.1.1\",\n+          \"pydantic==2.13.4\",\n+          \"ptyprocess>=0.7.0,<1; sys_platform != 'win32'\",\n+        ]\n+    \"\"\"))\n+    import hermes_cli.main as main_mod\n+    monkeypatch.setattr(main_mod, \"PROJECT_ROOT\", tmp_path)\n+    return tmp_path\n+\n+\n[redacted:email]\n+def fake_venv_python(tmp_path):\n+    \"\"\"Create a fake venv python shim path that exists on disk.\"\"\"\n+    venv_root = tmp_path / \"venv\"\n+    scripts = venv_root / \"Scripts\"\n+    scripts.mkdir(parents=True)\n+    py = scripts / \"python.exe\"\n+    py.write_text(\"#!/bin/sh\\necho fake python\")\n+    return py, venv_root\n+\n+\n+class TestVerifyCoreDependencies:\n+    def test_no_action_when_all_deps_present(self, temp_pyproject, fake_venv_python):\n+        \"\"\"The happy path: nothing missing, no repair install fires.\"\"\"\n+        py, venv_root = fake_venv_python\n+        env = {\"VIRTUAL_ENV\": str(venv_root)}\n+\n+        with patch(\"hermes_cli.main._resolve_install_target_python\", return_value=py), \\\n+             patch(\"hermes_cli.main.subprocess.run\") as mock_run, \\\n+             patch(\"hermes_cli.main._run_install_with_heartbeat\") as mock_install:\n+            mock_run.return_value = MagicMock(returncode=0, stdout=\"\", stderr=\"\")\n+\n+            from hermes_cli.main import _verify_core_dependencies_installed\n+            _verify_core_dependencies_installed([\"uv\", \"pip\"], env=env)\n+\n+            # Probe ran, repair install did not.\n+            assert mock_run.called, \"verification probe should have run\"\n+            assert not mock_install.called, \"repair install should not fire when nothing is missing\"\n+\n+    def test_triggers_reinstall_when_dep_missing(self, temp_pyproject, fake_venv_python):\n+        \"\"\"The regression: one base dep is missing → trigger --reinstall.\"\"\"\n+        py, venv_root = fake_venv_python\n+        env = {\"VIRTUAL_ENV\": str(venv_root)}\n+\n+        # First probe reports pathspec missing; after repair, probe is clean.\n+        probe_calls = {\"count\": 0}\n+\n+        def fake_subprocess_run(cmd, **kwargs):\n+            # The probe subprocess returns stdout = newline-joined missing names.\n+            probe_calls[\"count\"] += 1\n+            if probe_calls[\"count\"] == 1:\n+                return MagicMock(returncode=0, stdout=\"pathspec\\n\", stderr=\"\")\n+            return MagicMock(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        with patch(\"hermes_cli.main._resolve_install_target_python\", return_value=py), \\\n+             patch(\"hermes_cli.main.subprocess.run\", side_effect=fake_subprocess_run), \\\n+             patch(\"hermes_cli.main._run_install_with_heartbeat\") as mock_install:\n+\n+            from hermes_cli.main import _verify_core_dependencies_installed\n+            _verify_core_dependencies_installed([\"uv\", \"pip\"], env=env)\n+\n+            assert mock_install.called, \"repair install must fire when a dep is missing\"\n+            # First repair must use --reinstall to force re-resolution.\n+            first_repair = mock_install.call_args_list[0]\n+            args = first_repair[0][0]  # positional: install_cmd\n+            assert \"--reinstall\" in args, f\"repair install should pass --reinstall, got {args}\"\n+            assert \"-e\" in args and \".\" in args, \"first repair should be base group reinstall\"\n+\n+    def test_falls_back_to_per_package_install_when_reinstall_did_not_help(\n+        self, temp_pyproject, fake_venv_python\n+    ):\n+        \"\"\"If --reinstall doesn't repair the partial install (uv resolver\n+        thinks the env is satisfied), force-install each missing dep with\n+        its declared pin spec. This is the last-ditch path.\"\"\"\n+        py, venv_root = fake_venv_python\n+        env = {\"VIRTUAL_ENV\": str(venv_root)}\n+\n+        probe_calls = {\"count\": 0}\n+\n+        def fake_subprocess_run(cmd, **kwargs):\n+            probe_calls[\"count\"] += 1\n+            # 1st probe: pathspec missing\n+            # 2nd probe (after --reinstall): still missing\n+            # 3rd probe (after per-package): clean\n+            if probe_calls[\"count\"] in (1, 2):\n+                return MagicMock(returncode=0, stdout=\"pathspec\\n\", stderr=\"\")\n+            return MagicMock(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        with patch(\"hermes_cli.main._resolve_install_target_python\", return_value=py), \\\n+             patch(\"hermes_cli.main.subprocess.run\", side_effect=fake_subprocess_run), \\\n+             patch(\"hermes_cli.main._run_install_with_heartbeat\") as mock_install:\n+\n+            from hermes_cli.main import _verify_core_dependencies_installed\n+            _verify_core_dependencies_installed([\"uv\", \"pip\"], env=env)\n+\n+            assert mock_install.call_count >= 2, (\n+                \"expected at least 2 repair installs (reinstall + per-package), \"\n+                f\"got {mock_install.call_count}\"\n+            )\n+            # Second repair call should pass the pinned spec for the missing dep.\n+            second_repair_args = mock_install.call_args_list[1][0][0]\n+            assert any(\"pathspec==1.1.1\" in str(a) for a in second_repair_args), (\n+                f\"second repair should pin pathspec from pyproject; got {second_repair_args}\"\n+            )\n+\n+    def test_skips_deps_excluded_by_environment_markers(self, temp_pyproject, fake_venv_python):\n+        \"\"\"``ptyprocess ; sys_platform != 'win32'`` should NOT be reported as\n+        missing on Windows. Without marker evaluation, the verification step\n+        would false-positive on every cross-platform exclusion and chase its\n+        tail forever trying to install something that can't apply here.\"\"\"\n+        py, venv_root = fake_venv_python\n+        env = {\"VIRTUAL_ENV\": str(venv_root)}\n+        captured_argv: list[list[str]] = []\n+\n+        def fake_subprocess_run(cmd, **kwargs):\n+            captured_argv.append(list(cmd))\n+            return MagicMock(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        # Force sys.platform to look like Windows so the marker filters\n+        # ptyprocess out. (We need the actual marker.evaluate() to see win32.)\n+        with patch(\"hermes_cli.main._resolve_install_target_python\", return_value=py), \\\n+             patch(\"hermes_cli.main.subprocess.run\", side_effect=fake_subprocess_run), \\\n+             patch(\"hermes_cli.main._run_install_with_heartbeat\"), \\\n+             patch(\"sys.platform\", \"win32\"):\n+\n+            from hermes_cli.main import _verify_core_dependencies_installed\n+            _verify_core_dependencies_installed([\"uv\", \"pip\"], env=env)\n+\n+        # Find the probe argv — it's the call that passed the dep names.\n+        probe = next(\n+            (argv for argv in captured_argv if any(\"importlib.metadata\" in str(a) for a in argv)),\n+            None,\n+        )\n+        assert probe is not None, \"verification probe should have run\"\n+        # The dep names are tacked on after the -c script.\n+        assert \"ptyprocess\" not in probe, (\n+            \"ptyprocess is gated by sys_platform != 'win32' and must be filtered \"\n+            f\"out on Windows; full probe argv was: {probe}\"\n+        )\n+        assert \"pathspec\" in probe, \"core deps without markers must be checked\"\n+\n+    def test_no_pyproject_is_noop(self, tmp_path, monkeypatch):\n+        \"\"\"If pyproject.toml is missing (unusual but possible in some test\n+        envs), the verification step must short-circuit, not crash.\"\"\"\n+        import hermes_cli.main as main_mod\n+        monkeypatch.setattr(main_mod, \"PROJECT_ROOT\", tmp_path)\n+        # No pyproject.toml in tmp_path.\n+        with patch(\"hermes_cli.main._resolve_install_target_python\") as mock_resolve, \\\n+             patch(\"hermes_cli.main._run_install_with_heartbeat\") as mock_install:\n+            from hermes_cli.main import _verify_core_dependencies_installed\n+            _verify_core_dependencies_installed([\"uv\", \"pip\"], env={})\n+            assert not mock_resolve.called\n+            assert not mock_install.called\n+\n+\n+class TestResolveInstallTargetPython:\n+    def test_uses_virtual_env_from_environment(self, tmp_path):\n+        \"\"\"When VIRTUAL_ENV is set, the verification step must probe THAT\n+        venv's interpreter — not the outer Python that drove `hermes update`.\n+        If we probed sys.executable instead, we'd false-positive every dep\n+        the outer interpreter happens to lack.\"\"\"\n+        venv_root = tmp_path / \"newvenv\"\n+        scripts = venv_root / \"Scripts\"\n+        scripts.mkdir(parents=True)\n+        py = scripts / \"python.exe\"\n+        py.write_text(\"fake\")\n+\n+        with patch(\"hermes_cli.main._is_windows\", return_value=True):\n+            from hermes_cli.main import _resolve_install_target_python\n+            result = _resolve_install_target_python(\n+                [\"uv\", \"pip\"], env={\"VIRTUAL_ENV\": str(venv_root)}\n+            )\n+            assert result == py\n+\n+    def test_returns_none_when_venv_python_missing(self, tmp_path):\n+        \"\"\"If the path we'd point at doesn't exist (uv install failed before\n+        the python shim landed), return None so the verification step\n+        cleanly short-circuits instead of crashing on FileNotFoundError.\"\"\"\n+        with patch(\"hermes_cli.main._is_windows\", return_value=True):\n+            from hermes_cli.main import _resolve_install_target_python\n+            result = _resolve_install_target_python(\n+                [\"uv\", \"pip\"], env={\"VIRTUAL_ENV\": str(tmp_path / \"does_not_exist\")}\n+            )\n+            assert result is None\n"]}]},{"kind":"pr","number":39044,"title":"fix(cli): pass --clear to uv venv in rebuild_venv to avoid Windows brick","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c136eb4de","number":0},{"kind":"pr","value":"#37881","number":37881}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}