{"repo":"NousResearch/hermes-agent","public_id":"39549","display_label":"hermes update command crashes and corrupts state","summary":"Multiple bugs and fixes related to the 'hermes update' command failing with errors (including a ValueError unpack crash), leaving installations in a broken or half-updated state with no clean recovery path. Work addresses crash prevention, subprocess isolation, venv handling, and stuck-state markers.","subsystem":"hermes_cli","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39780},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-10T09:57:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.23222222222222222},"fix_edges":[{"issue_number":39549,"pr_number":39780,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39549,"pr_number":42172,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39706,"pr_number":39780,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":39549,"title":"Bug: `hermes update` aborts with `ValueError: too many values to unpack (expected 2)` in `_build_web_ui`; leaves installation in half-updated state with no way to retry","url":null,"state":"closed"},{"kind":"issue","number":39706,"title":"hermes update crashes with `ValueError: too many values to unpack (expected 2)` during post-pull dependency install","url":null,"state":"closed"},{"kind":"pr","number":39557,"title":"fix(cli): catch _build_web_ui exceptions to prevent half-updated state","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db204ae2035021bb5f1e76004c13296853a139b6","number":0},{"kind":"pr","value":"#39780","number":39780}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39780,"title":"fix(update): make ensure_uv() survive the update boundary (no first-run crash)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/managed_uv.py","tests/hermes_cli/test_managed_uv.py"],"fix_hunks":[{"file":"hermes_cli/managed_uv.py","hunks":["@@ -51,15 +51,54 @@ def resolve_uv() -> Optional[str]:\n     return None\n \n \n-def ensure_uv() -> Optional[str]:\n+class _UvResult(str):\n+    \"\"\"``ensure_uv()`` return value that survives an update boundary.\n+\n+    ``ensure_uv()``'s arity has flipped between a single path string and a\n+    ``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs\n+    the call site from the *old*, already-imported ``hermes_cli.main`` against\n+    this *freshly pulled* module, so the two can disagree on how many values\n+    ``ensure_uv()`` returns. An install parked on a 2-tuple release runs\n+    ``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module\n+    and crashes the first update: the returned path is a plain ``str``, which is\n+    itself iterable, so the 2-target unpack walks its characters and raises\n+    ``ValueError: too many values to unpack (expected 2)`` (and on the failure\n+    path the ``None`` return raises ``TypeError: cannot unpack non-iterable\n+    NoneType``). This wrapper answers to both conventions:\n+\n+        uv_bin = ensure_uv()         # behaves as the path str (\"\" when absent)\n+        uv_bin, fresh = ensure_uv()  # unpacks as (path|None, fresh_bootstrap)\n+\n+    Missing uv is the empty string (falsy) instead of ``None`` so legacy\n+    2-target call sites can still unpack a failure without raising, while\n+    ``if not uv_bin`` keeps working for single-value callers.\n+    \"\"\"\n+\n+    fresh_bootstrap: bool\n+\n+    def __new__(cls, path: Optional[str], fresh: bool = False) -> \"_UvResult\":\n+        self = super().__new__(cls, path or \"\")\n+        self.fresh_bootstrap = fresh\n+        return self\n+\n+    def __iter__(self):\n+        # Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.\n+        # First element mirrors the historical contract: the path string, or\n+        # ``None`` when uv is unavailable.\n+        return iter(((str(self) or None), self.fresh_bootstrap))\n+\n+\n+def ensure_uv() -> \"_UvResult\":\n     \"\"\"Return the managed uv path, installing it first if necessary.\n \n-    On failure returns ``None`` (never raises) so callers can fall\n-    back to pip gracefully.\n+    Returns a :class:`_UvResult` (a ``str`` subclass) that is both usable\n+    directly as the path and unpackable as ``(path, fresh_bootstrap)`` for\n+    older call sites. On failure the result is falsy (``\"\"``) — never raises —\n+    so callers can fall back to pip gracefully.\n     \"\"\"\n     existing = resolve_uv()\n     if existing:\n-        return existing\n+        return _UvResult(existing)\n \n     target = managed_uv_path()\n     target.parent.mkdir(parents=True, exist_ok=True)","@@ -71,7 +110,7 @@ def ensure_uv() -> Optional[str]:\n     except Exception as exc:\n         logger.warning(\"Managed uv install failed: %s\", exc)\n         print(f\"  ✗ Failed to install managed uv: {exc}\")\n-        return None\n+        return _UvResult(None)\n \n     # Verify\n     result = resolve_uv()","@@ -85,7 +124,7 @@ def ensure_uv() -> Optional[str]:\n         print(f\"  ✓ Managed uv installed ({version})\")\n     else:\n         print(\"  ✗ Managed uv install appeared to succeed but binary not found\")\n-    return result\n+    return _UvResult(result)\n \n \n def update_managed_uv() -> Optional[str]:"]},{"file":"tests/hermes_cli/test_managed_uv.py","hunks":["@@ -92,12 +92,55 @@ def fake_install(target):\n             assert path == str(tmp_path / \"bin\" / \"uv\")\n             mock_install.assert_called_once()\n \n-    def test_install_failure_returns_none(self, tmp_path):\n+    def test_install_failure_returns_falsy(self, tmp_path):\n         with patch(\"hermes_cli.managed_uv.get_hermes_home\", return_value=tmp_path), \\\n              patch(\"hermes_cli.managed_uv._install_uv\", side_effect=RuntimeError(\"network down\")):\n             from hermes_cli.managed_uv import ensure_uv\n             path = ensure_uv()\n-            assert path is None\n+            # Failure is a falsy sentinel (not None) so legacy 2-target call\n+            # sites can still unpack it without raising — see\n+            # TestEnsureUvUpdateBoundary for why.\n+            assert not path\n+\n+\n+class TestEnsureUvUpdateBoundary:\n+    \"\"\"``ensure_uv()`` must answer to both the single-value and the legacy\n+    ``(path, fresh_bootstrap)`` call conventions.\n+\n+    ``hermes update`` runs the call site from the old, already-imported\n+    ``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release\n+    parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``\n+    against the single-value module; the path is an iterable ``str`` so the\n+    2-target unpack walked its characters and raised\n+    ``ValueError: too many values to unpack (expected 2)`` (root cause behind\n+    PR #39763), or ``TypeError`` on the ``None`` failure path. The result must\n+    therefore be usable as a bare path *and* unpackable as a 2-tuple, in both\n+    the success and failure cases.\n+    \"\"\"\n+\n+    def test_success_usable_as_single_value(self, tmp_path):\n+        _make_executable(tmp_path / \"bin\" / \"uv\")\n+        with patch(\"hermes_cli.managed_uv.get_hermes_home\", return_value=tmp_path):\n+            from hermes_cli.managed_uv import ensure_uv\n+            uv_bin = ensure_uv()\n+            assert uv_bin == str(tmp_path / \"bin\" / \"uv\")\n+            assert bool(uv_bin) is True\n+\n+    def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):\n+        _make_executable(tmp_path / \"bin\" / \"uv\")\n+        with patch(\"hermes_cli.managed_uv.get_hermes_home\", return_value=tmp_path):\n+            from hermes_cli.managed_uv import ensure_uv\n+            uv_bin, fresh = ensure_uv()  # old: uv_bin, fresh_bootstrap = ensure_uv()\n+            assert uv_bin == str(tmp_path / \"bin\" / \"uv\")\n+            assert fresh is False\n+\n+    def test_failure_unpacks_without_raising(self, tmp_path):\n+        with patch(\"hermes_cli.managed_uv.get_hermes_home\", return_value=tmp_path), \\\n+             patch(\"hermes_cli.managed_uv._install_uv\", side_effect=RuntimeError(\"network down\")):\n+            from hermes_cli.managed_uv import ensure_uv\n+            uv_bin, fresh = ensure_uv()\n+            assert uv_bin is None\n+            assert fresh is False\n \n \n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":40191,"title":"fix(cli): repair hermes update path (venv detection, uv arity, v25→v26 migration)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40757,"title":"fix(cli): add update-in-progress marker to prevent stuck state after crash (PR 1/2)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a5c32cdf3055c1047bf08e1beb0334ad92672063","number":0},{"kind":"pr","value":"#42172","number":42172}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40758,"title":"fix(cli): spawn post-pull steps in subprocess to fix bytecode version skew (PR 2/2)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db204ae2035021bb5f1e76004c13296853a139b6","number":0},{"kind":"pr","value":"#39549","number":39549},{"kind":"pr","value":"#39549","number":39549}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40995,"title":"Fix #39706: Remove incomplete rebuild_venv stub causing update crash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db204ae2035021bb5f1e76004c13296853a139b6","number":0},{"kind":"pr","value":"#39780","number":39780},{"kind":"pr","value":"#39706","number":39706}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42172,"title":"fix(update): self-heal a venv left half-built by an interrupted install","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".gitignore","hermes_cli/main.py","tests/hermes_cli/test_update_interrupted_recovery.py"],"fix_hunks":[{"file":".gitignore","hunks":["@@ -114,6 +114,12 @@ docs/superpowers/*\n # treat it as a local edit and autostash it on every run (#38529).\n .hermes-bootstrap-complete\n \n+# Interrupted-update breadcrumb + recovery lock written next to the shared venv\n+# by `hermes update` / launch-time self-heal. Runtime state, never a code change\n+# — ignore so `git status` stays clean and update's autostash skips them.\n+.update-incomplete\n+.update-incomplete.lock\n+\n # Tool Search live-test harness output — non-deterministic model transcripts,\n # regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.\n scripts/out/"]},{"file":"hermes_cli/main.py","hunks":["@@ -8832,6 +8832,167 @@ def _load_installable_optional_extras(group: str = \"all\") -> list[str]:\n     return referenced\n \n \n+# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the\n+# venv and cleared only after the dependency install verifies clean.  If a user\n+# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker\n+# survives and the next ``hermes`` launch finishes the install instead of\n+# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow\n+# never landed).  Lives next to the venv (not under $HERMES_HOME) because the\n+# venv is shared across all profiles, so a single marker covers every profile.\n+def _update_marker_path() -> Path:\n+    return PROJECT_ROOT / \".update-incomplete\"\n+\n+\n+def _write_update_incomplete_marker() -> None:\n+    \"\"\"Drop the interrupted-install breadcrumb. Never raises.\"\"\"\n+    try:\n+        _update_marker_path().write_text(\n+            f\"started={_time.time()}\\npid={os.getpid()}\\n\", encoding=\"utf-8\"\n+        )\n+    except OSError as exc:\n+        logger.debug(\"Could not write update-incomplete marker: %s\", exc)\n+\n+\n+def _clear_update_incomplete_marker() -> None:\n+    \"\"\"Remove the interrupted-install breadcrumb. Never raises.\"\"\"\n+    try:\n+        _update_marker_path().unlink()\n+    except FileNotFoundError:\n+        pass\n+    except OSError as exc:\n+        logger.debug(\"Could not clear update-incomplete marker: %s\", exc)\n+\n+\n+def _recover_from_interrupted_install() -> None:\n+    \"\"\"Finish a dependency install that a prior ``hermes update`` left half-done.\n+\n+    Triggered on launch when ``.update-incomplete`` is present — meaning the\n+    code was pulled but the dep install was killed before it verified clean.\n+    Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``\n+    can wipe pip from the venv entirely, which blocks the venv from recovering\n+    on its own), then re-runs the editable ``.[all]`` install + core-dependency\n+    verification, then clears the marker.\n+\n+    Never raises: a recovery failure must not block launch.  If it can't\n+    self-heal it prints the one-line manual command and leaves the marker so\n+    the next launch tries again.\n+\n+    Concurrency: the marker lives next to the shared venv, so a gateway start\n+    plus a CLI launch (or two profiles starting at once) can both see it.  An\n+    ``O_EXCL`` lockfile ensures only one process runs the reinstall; the\n+    others skip and let the winner clear the marker.\n+\n+    Output: everything — our status lines AND the streamed pip/uv install\n+    (which inherits fd 1) — is routed to stderr.  Launches whose stdout is a\n+    protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get\n+    install noise on stdout.\n+    \"\"\"\n+    if not _update_marker_path().exists():\n+        return\n+\n+    # Skip in managed/Docker installs and on PyPI installs with no git checkout:\n+    # those don't run the source-tree update path, so a stray marker is not ours\n+    # to act on. Just clear it.\n+    if not (PROJECT_ROOT / \"pyproject.toml\").is_file():\n+        _clear_update_incomplete_marker()\n+        return\n+\n+    # Single-flight guard: atomically claim the recovery lock. If another\n+    # process holds it, skip — it is running the same reinstall into the same\n+    # shared venv right now. A crashed holder leaves a stale lock; break it\n+    # after an hour (well past any realistic install) so recovery can't be\n+    # wedged forever.\n+    lock_path = PROJECT_ROOT / \".update-incomplete.lock\"\n+    try:\n+        fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)\n+        os.write(fd, f\"{os.getpid()}\\n\".encode())\n+        os.close(fd)\n+    except FileExistsError:\n+        try:\n+            if _time.time() - lock_path.stat().st_mtime > 3600:\n+                lock_path.unlink()\n+        except OSError:\n+            pass\n+        return\n+    except OSError as exc:\n+        # Couldn't create the lock (read-only fs, perms). Proceed unlocked —\n+        # the install itself will surface the real problem.\n+        logger.debug(\"Could not create install-recovery lock: %s\", exc)\n+\n+    saved_stdout_fd = None\n+    saved_sys_stdout = sys.stdout\n+    try:\n+        # Route Python-level prints AND subprocess-inherited fd 1 to stderr\n+        # for the duration of recovery (see docstring: ACP stdout safety).\n+        try:\n+            saved_stdout_fd = os.dup(1)\n+            os.dup2(2, 1)\n+        except OSError:\n+            saved_stdout_fd = None\n+        sys.stdout = sys.stderr\n+\n+        print(\n+            \"⚠ A previous `hermes update` was interrupted mid-install — \"\n+            \"finishing dependency installation now...\"\n+        )\n+\n+        try:\n+            from hermes_cli.managed_uv import ensure_uv\n+\n+            # Always bootstrap pip first: a killed install can leave the venv with\n+            # no pip module at all, and uv may also be gone. ensurepip restores a\n+            # known-good pip so at least the plain-pip path below can proceed.\n+            try:\n+                subprocess.run(\n+                    [sys.executable, \"-m\", \"ensurepip\", \"--upgrade\", \"--default-pip\"],\n+                    cwd=PROJECT_ROOT,\n+                    capture_output=True,\n+                )\n+            except Exception as exc:\n+                logger.debug(\"ensurepip during install recovery failed: %s\", exc)\n+\n+            uv_bin = ensure_uv()\n+            if uv_bin:\n+                uv_env = {**os.environ, \"VIRTUAL_ENV\": str(PROJECT_ROOT / \"venv\")}\n+                if _is_termux_env(uv_env):\n+                    uv_env.pop(\"PYTHONPATH\", None)\n+                    uv_env.pop(\"PYTHONHOME\", None)\n+                _install_python_dependencies_with_optional_fallback(\n+                    [uv_bin, \"pip\"],\n+                    env=uv_env,\n+                    group=\"termux-all\" if _is_termux_env(uv_env) else \"all\",\n+                )\n+            else:\n+                _install_python_dependencies_with_optional_fallback(\n+                    [sys.executable, \"-m\", \"pip\"],\n+                    group=\"termux-all\" if _is_termux_env() else \"all\",\n+                )\n+\n+            _clear_update_incomplete_marker()\n+            print(\"✓ Dependency installation recovered — your install is healthy again.\")\n+        except Exception as exc:\n+            # Leave the marker in place so the next launch retries. Give the user\n+            # the exact manual recovery command in the meantime.\n+            logger.debug(\"Interrupted-install recovery failed: %s\", exc)\n+            print(\"✗ Could not auto-recover the interrupted install.\")\n+            print(\"  Recover manually with:\")\n+            print(f\"    cd {PROJECT_ROOT}\")\n+            print(f\"    {sys.executable} -m ensurepip --upgrade\")\n+            print(f\"    {sys.executable} -m pip install -e '.[all]'\")\n+    finally:\n+        sys.stdout = saved_sys_stdout\n+        if saved_stdout_fd is not None:\n+            try:\n+                os.dup2(saved_stdout_fd, 1)\n+                os.close(saved_stdout_fd)\n+            except OSError:\n+                pass\n+        try:\n+            lock_path.unlink()\n+        except OSError:\n+            pass\n+\n+\n def _run_install_with_heartbeat(\n     cmd: list[str],\n     *,","@@ -10759,6 +10920,13 @@ def _cmd_update_impl(args, gateway_mode: bool):\n         # Reinstall Python dependencies. Prefer .[all], but if one optional extra\n         # breaks on this machine, keep base deps and reinstall the remaining extras\n         # individually so update does not silently strip working capabilities.\n+        #\n+        # Drop the interrupted-install breadcrumb BEFORE touching the venv. If\n+        # the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),\n+        # the marker survives and the next ``hermes`` launch finishes the\n+        # install via ``_recover_from_interrupted_install``. Cleared only after\n+        # the install + core-dependency verification completes below.\n+        _write_update_incomplete_marker()\n         print(\"→ Updating Python dependencies...\")\n         from hermes_cli.managed_uv import ensure_uv, update_managed_uv\n ","@@ -10812,6 +10980,12 @@ def _cmd_update_impl(args, gateway_mode: bool):\n                 _install_psutil_android_compat(pip_cmd)\n             _install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)\n \n+        # Core Python deps installed AND verified (the fallback helper runs\n+        # _verify_core_dependencies_installed). Clear the interrupted-install\n+        # breadcrumb now — the remaining steps (lazy refresh, node deps, web\n+        # UI, desktop rebuild) are non-core and can't brick the venv.\n+        _clear_update_incomplete_marker()\n+\n         _refresh_active_lazy_features()\n \n         _update_node_dependencies()","@@ -13110,6 +13284,22 @@ def main():\n     except Exception:\n         pass\n \n+    # Self-heal a venv left half-built by an interrupted ``hermes update``\n+    # (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is\n+    # *running* update — that flow writes and clears its own marker, and we\n+    # don't want a recovery install racing the real one. Never raises.\n+    #\n+    # The substring match is deliberately loose: argv isn't parsed yet at this\n+    # point, and the failure modes are asymmetric. Over-matching (e.g.\n+    # ``hermes skills install update``) merely defers recovery one launch;\n+    # under-matching (missing ``hermes -p work update``) would race a recovery\n+    # install against the real one. Loose wins.\n+    try:\n+        if \"update\" not in sys.argv[1:]:\n+            _recover_from_interrupted_install()\n+    except Exception:\n+        pass\n+\n     if _try_termux_fast_tui_launch():\n         return\n     if _try_termux_fast_cli_launch():"]},{"file":"tests/hermes_cli/test_update_interrupted_recovery.py","hunks":["@@ -0,0 +1,218 @@\n+\"\"\"Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).\n+\n+Covers the breadcrumb lifecycle and the launch-time recovery guard added so a\n+``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets\n+finished automatically on the next launch instead of leaving a half-built venv.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from pathlib import Path\n+\n+import hermes_cli.main as m\n+\n+\n+def test_marker_round_trip(tmp_path, monkeypatch):\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    marker = m._update_marker_path()\n+    assert marker == tmp_path / \".update-incomplete\"\n+    assert not marker.exists()\n+\n+    m._write_update_incomplete_marker()\n+    assert marker.exists()\n+    body = marker.read_text()\n+    assert \"started=\" in body\n+    assert \"pid=\" in body\n+\n+    m._clear_update_incomplete_marker()\n+    assert not marker.exists()\n+\n+\n+def test_clear_when_absent_is_noop(tmp_path, monkeypatch):\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    # Must not raise when the marker was never written.\n+    m._clear_update_incomplete_marker()\n+    assert not m._update_marker_path().exists()\n+\n+\n+def test_recovery_noop_without_marker(tmp_path, monkeypatch):\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    called = {\"install\": False}\n+    monkeypatch.setattr(\n+        m,\n+        \"_install_python_dependencies_with_optional_fallback\",\n+        lambda *a, **k: called.__setitem__(\"install\", True),\n+    )\n+    m._recover_from_interrupted_install()\n+    assert called[\"install\"] is False, \"recovery must not install when no marker\"\n+\n+\n+def test_recovery_clears_stray_marker_without_pyproject(tmp_path, monkeypatch):\n+    # No pyproject.toml (PyPI/Docker install) — a stray marker is not ours to\n+    # act on; recovery should just clear it without trying to install.\n+    monkeypatch.setattr(m, \"PROJECT_ROOT\", tmp_path)\n+    m._write_update_incomplete_marker()\n+    called = {\"install\": False}\n+    monkeypatch.setattr(\n+        m,\n+        \"_install_python_dependencies_with_optional_fallback\",\n+        lambda *a, **k: called.__setitem__(\"install\", True),\n+    )\n+    m._recover_from_interrupted_install()\n+    assert called[\"install\"] is False\n+    assert not m._update_marker_path().exists()\n+\n+\n+def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):\n+    # Source-tree install (pyproject present) with marker set → recovery should\n+    # run the dep install and clear the marker on success.\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+    seen = {\"ensurepip\": False, \"install\": False}\n+\n+    def fake_run(cmd, *a, **k):\n+        if \"ensurepip\" in cmd:\n+            seen[\"ensurepip\"] = True\n+\n+        class R:\n+            returncode = 0\n+\n+        return R()\n+\n+    monkeypatch.setattr(m.subprocess, \"run\", fake_run)\n+    monkeypatch.setattr(m, \"_is_termux_env\", lambda *a, **k: False)\n+    monkeypatch.setattr(\"hermes_cli.managed_uv.ensure_uv\", lambda: None)\n+    monkeypatch.setattr(\n+        m,\n+        \"_install_python_dependencies_with_optional_fallback\",\n+        lambda *a, **k: seen.__setitem__(\"install\", True),\n+    )\n+\n+    m._recover_from_interrupted_install()\n+\n+    assert seen[\"ensurepip\"] is True, \"ensurepip must run unconditionally first\"\n+    assert seen[\"install\"] is True, \"dep install must run\"\n+    assert not m._update_marker_path().exists(), \"marker cleared on success\"\n+\n+\n+def test_recovery_keeps_marker_on_failure(tmp_path, monkeypatch):\n+    # If the install itself blows up, the marker must survive so the next\n+    # launch retries — and recovery must not raise.\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+    class R:\n+        returncode = 0\n+\n+    monkeypatch.setattr(m.subprocess, \"run\", lambda *a, **k: R())\n+    monkeypatch.setattr(m, \"_is_termux_env\", lambda *a, **k: False)\n+    monkeypatch.setattr(\"hermes_cli.managed_uv.ensure_uv\", lambda: None)\n+\n+    def boom(*a, **k):\n+        raise RuntimeError(\"install died\")\n+\n+    monkeypatch.setattr(\n+        m, \"_install_python_dependencies_with_optional_fallback\", boom\n+    )\n+\n+    # Must not raise.\n+    m._recover_from_interrupted_install()\n+    assert m._update_marker_path().exists(), \"marker preserved for retry on failure\"\n+\n+\n+def _stub_install_env(monkeypatch, m, seen):\n+    \"\"\"Common stubs so recovery's install path is inert and observable.\"\"\"\n+\n+    class R:\n+        returncode = 0\n+\n+    monkeypatch.setattr(m.subprocess, \"run\", lambda *a, **k: R())\n+    monkeypatch.setattr(m, \"_is_termux_env\", lambda *a, **k: False)\n+    monkeypatch.setattr(\"hermes_cli.managed_uv.ensure_uv\", lambda: None)\n+    monkeypatch.setattr(\n+        m,\n+        \"_install_python_dependencies_with_optional_fallback\",\n+        lambda *a, **k: seen.__setitem__(\"install\", True),\n+    )\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+    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+    lock = tmp_path / \".update-incomplete.lock\"\n+    lock.write_text(\"12345\\n\")\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, \"must not install while another holds the lock\"\n+    assert m._update_marker_path().exists(), \"marker left for the lock holder\"\n+    assert lock.exists(), \"fresh lock must not be broken\"\n+\n+\n+def test_recovery_breaks_stale_lock(tmp_path, monkeypatch):\n+    # A lock older than an hour is from a crashed holder — it gets removed so\n+    # the NEXT launch can recover (this launch still skips).\n+    import os as _os\n+\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+    lock = tmp_path / \".update-incomplete.lock\"\n+    lock.write_text(\"12345\\n\")\n+    stale = m._time.time() - 7200\n+    _os.utime(lock, (stale, stale))\n+\n+    seen = {\"install\": False}\n+    _stub_install_env(monkeypatch, m, seen)\n+\n+    m._recover_from_interrupted_install()\n+\n+    assert not lock.exists(), \"stale lock must be broken\"\n+    assert m._update_marker_path().exists()\n+\n+    # Next launch proceeds normally.\n+    m._recover_from_interrupted_install()\n+    assert seen[\"install\"] is True\n+    assert not m._update_marker_path().exists()\n+    assert not lock.exists(), \"lock released after recovery\"\n+\n+\n+def test_recovery_releases_lock_after_run(tmp_path, monkeypatch):\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+    seen = {\"install\": False}\n+    _stub_install_env(monkeypatch, m, seen)\n+\n+    m._recover_from_interrupted_install()\n+\n+    assert seen[\"install\"] is True\n+    assert not (tmp_path / \".update-incomplete.lock\").exists()\n+\n+\n+def test_recovery_output_goes_to_stderr(tmp_path, monkeypatch, capfd):\n+    # ACP speaks JSON-RPC on stdout — recovery output (including the streamed\n+    # install, which inherits fd 1) must land on stderr only.\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+    seen = {\"install\": False}\n+    _stub_install_env(monkeypatch, m, seen)\n+\n+    m._recover_from_interrupted_install()\n+\n+    out, err = capfd.readouterr()\n+    assert \"interrupted mid-install\" not in out\n+    assert \"interrupted mid-install\" in err\n+    assert \"recovered\" in err\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}