{"repo":"NousResearch/hermes-agent","public_id":"14898","display_label":"Web UI rebuild triggering logic fixes and controls","summary":"These issues and PRs address problems with the dashboard or CLI unnecessarily rebuilding the web UI on startup, including stale-detection path bugs, OOM on low-memory systems, and adding options to skip or defer builds.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16292},"resolution":{"state":"partially_resolved","by":"pr-16292"},"freshness":{"state":"verified","last_verified_at":"2026-04-27T01:43:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":12243,"pr_number":16292,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14898,"pr_number":16292,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14898,"title":"Dashboard rebuilds web UI on every startup - causes OOM on low-memory VPS (≤1GB)","url":null,"state":"closed"},{"kind":"issue","number":32533,"title":"fix(dashboard): systemd service crash-loop when web_dist exists but npm unavailable","url":null,"state":"closed"},{"kind":"pr","number":14914,"title":"fix(cli): skip web UI rebuild when dist/ is present and sources are unchanged","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16292","number":16292},{"kind":"pr","value":"#15863","number":15863},{"kind":"pr","value":"#15863","number":15863}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":15863,"title":"fix(cli): check hermes_cli/web_dist/ not web/dist/ for build staleness (fixes #14914 regression)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16292","number":16292}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16292,"title":"fix(cli): skip web UI rebuild when dist is fresh (salvage #15863, closes #14898)","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","scripts/release.py","tests/hermes_cli/test_web_ui_build.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -4984,6 +4984,43 @@ def _gateway_prompt(prompt_text: str, default: str = \"\", timeout: float = 300.0)\n     return default\n \n \n+def _web_ui_build_needed(web_dir: Path) -> bool:\n+    \"\"\"Return True if the web UI dist is missing or stale.\n+\n+    Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.\n+    The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts\n+    outDir: \"../hermes_cli/web_dist\"), NOT to ``web/dist/``.  Uses the Vite\n+    manifest as the sentinel because it is written last and therefore has the\n+    newest mtime of any build output.\n+    \"\"\"\n+    dist_dir = web_dir.parent / \"hermes_cli\" / \"web_dist\"\n+    sentinel = dist_dir / \".vite\" / \"manifest.json\"\n+    if not sentinel.exists():\n+        sentinel = dist_dir / \"index.html\"\n+    if not sentinel.exists():\n+        return True\n+    dist_mtime = sentinel.stat().st_mtime\n+    skip = frozenset({\"node_modules\", \"dist\"})\n+    for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):\n+        dirnames[:] = [d for d in dirnames if d not in skip]\n+        for fn in filenames:\n+            if fn.endswith((\".ts\", \".tsx\", \".js\", \".jsx\", \".css\", \".html\", \".vue\")):\n+                if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:\n+                    return True\n+    for meta in (\n+        \"package.json\",\n+        \"package-lock.json\",\n+        \"yarn.lock\",\n+        \"pnpm-lock.yaml\",\n+        \"vite.config.ts\",\n+        \"vite.config.js\",\n+    ):\n+        mp = web_dir / meta\n+        if mp.exists() and mp.stat().st_mtime > dist_mtime:\n+            return True\n+    return False\n+\n+\n def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:\n     \"\"\"Build the web UI frontend if npm is available.\n ","@@ -4997,6 +5034,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:\n     if not (web_dir / \"package.json\").exists():\n         return True\n \n+    if not _web_ui_build_needed(web_dir):\n+        return True\n+\n     npm = shutil.which(\"npm\")\n     if not npm:\n         if fatal:"]},{"file":"scripts/release.py","hunks":["@@ -54,6 +54,7 @@\n     \"[redacted:email]\": \"benjaminsehl\",\n     \"[redacted:email]\": \"Nerijusas\",\n     \"[redacted:email]\": \"Ito-69\",\n+    \"[redacted:email]\": \"georgeglessner\",\n     \"[redacted:email]\": \"maxims-oss\",\n     # contributors (from noreply pattern)\n     \"[redacted:email]\": \"davidvv\","]},{"file":"tests/hermes_cli/test_web_ui_build.py","hunks":["@@ -0,0 +1,121 @@\n+\"\"\"Tests for _web_ui_build_needed — staleness check for the web UI dist.\n+\n+Critical invariant: the Vite build outputs to hermes_cli/web_dist/\n+(vite.config.ts: outDir: \"../hermes_cli/web_dist\"), NOT web/dist/.\n+The sentinel must be checked in the correct output directory or the\n+freshness check is a no-op and the OOM rebuild always runs.\n+\"\"\"\n+\n+import os\n+import time\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from hermes_cli.main import _web_ui_build_needed, _build_web_ui\n+\n+\n+def _touch(path: Path, offset: float = 0.0) -> None:\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    path.touch()\n+    if offset:\n+        t = time.time() + offset\n+        os.utime(path, (t, t))\n+\n+\n+def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]:\n+    \"\"\"Return (web_dir, dist_dir) matching real repo layout.\"\"\"\n+    web_dir = tmp_path / \"web\"\n+    web_dir.mkdir()\n+    (web_dir / \"package.json\").touch()\n+    dist_dir = tmp_path / \"hermes_cli\" / \"web_dist\"\n+    return web_dir, dist_dir\n+\n+\n+class TestWebUIBuildNeeded:\n+\n+    def test_returns_true_when_dist_missing(self, tmp_path):\n+        web_dir, _ = _make_web_dir(tmp_path)\n+        assert _web_ui_build_needed(web_dir) is True\n+\n+    def test_returns_false_when_vite_manifest_fresh(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(web_dir / \"src\" / \"App.tsx\", offset=-10)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\")\n+        assert _web_ui_build_needed(web_dir) is False\n+\n+    def test_returns_true_when_source_newer_than_manifest(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\", offset=-10)\n+        _touch(web_dir / \"src\" / \"App.tsx\")\n+        assert _web_ui_build_needed(web_dir) is True\n+\n+    def test_falls_back_to_index_html_when_manifest_missing(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(web_dir / \"src\" / \"main.ts\", offset=-10)\n+        _touch(dist_dir / \"index.html\")\n+        assert _web_ui_build_needed(web_dir) is False\n+\n+    def test_web_dist_dir_not_web_dist_subdir(self, tmp_path):\n+        \"\"\"Regression: sentinel must be in hermes_cli/web_dist/, NOT web/dist/.\"\"\"\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(web_dir / \"src\" / \"App.tsx\", offset=-10)\n+        # Place manifest in wrong location (web/dist/) — should NOT count as fresh\n+        wrong_dist = web_dir / \"dist\" / \".vite\" / \"manifest.json\"\n+        _touch(wrong_dist)\n+        # Correct location is empty → still needs build\n+        assert _web_ui_build_needed(web_dir) is True\n+\n+    def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\", offset=-10)\n+        _touch(web_dir / \"package-lock.json\")\n+        assert _web_ui_build_needed(web_dir) is True\n+\n+    def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\", offset=-10)\n+        _touch(web_dir / \"vite.config.ts\")\n+        assert _web_ui_build_needed(web_dir) is True\n+\n+    def test_ignores_node_modules(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        # package.json older than manifest; only node_modules file is newer\n+        _touch(web_dir / \"package.json\", offset=-20)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\", offset=-10)\n+        _touch(web_dir / \"node_modules\" / \"react\" / \"index.js\")\n+        assert _web_ui_build_needed(web_dir) is False\n+\n+    def test_ignores_dist_subdir_under_web(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        # package.json older than manifest; only web/dist file is newer\n+        _touch(web_dir / \"package.json\", offset=-20)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\", offset=-10)\n+        _touch(web_dir / \"dist\" / \"assets\" / \"index.js\")\n+        assert _web_ui_build_needed(web_dir) is False\n+\n+\n+class TestBuildWebUISkipsWhenFresh:\n+\n+    def test_skips_npm_when_dist_is_fresh(self, tmp_path):\n+        web_dir, dist_dir = _make_web_dir(tmp_path)\n+        _touch(dist_dir / \".vite\" / \"manifest.json\")\n+\n+        with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n+             patch(\"hermes_cli.main.subprocess.run\") as mock_run:\n+            result = _build_web_ui(web_dir)\n+\n+        assert result is True\n+        mock_run.assert_not_called()\n+\n+    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+        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+            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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}