Skip to content

feat(update): skip npm ci when node_modules already matches lockfile (#17268)#17386

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:feat/update-skip-npm-when-lockfile-matches-17268
Closed

feat(update): skip npm ci when node_modules already matches lockfile (#17268)#17386
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:feat/update-skip-npm-when-lockfile-matches-17268

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • hermes update ran npm ci unconditionally in the repo root and ui-tui/ on every run, even when nothing had changed
  • npm ci deletes node_modules and re-installs from scratch — the slow path on metered or restricted networks (feat: Skip npm install during updates when Node dependencies haven't changed #17268 reports 30–60s+ stalls)
  • Skip the call when package-lock.json content already matches node_modules/.package-lock.json (npm's hidden lockfile)

The bug

_update_node_dependencies() in hermes_cli/main.py always called _run_npm_install_deterministic for every directory with a package.json. There was no "is anything actually out of date?" check before paying the npm ci cost — which is high precisely because npm ci is intentionally destructive (it removes node_modules first), so even a no-op update pays for a full registry handshake plus a clean re-install.

The TUI launcher already had a content-based lockfile comparison in _tui_need_npm_install, but it was scoped to the TUI's @hermes/ink presence guard and never called from the update path.

The fix

Extract the lockfile-content comparison into _npm_install_in_sync(root):

  • Compares package-lock.json against node_modules/.package-lock.json (npm's hidden lockfile) by content, not mtime — so git checkouts and npm rewrites that bump lockfile timestamps don't cause spurious reinstalls
  • Returns True only when every required (non-optional, non-peer) entry in the committed lockfile matches the hidden lockfile, ignoring npm-written runtime annotations like ideallyInert
  • Returns False when either lockfile is missing (defensive default — let the existing install path handle bootstrap and recovery)
  • Falls back to mtime comparison if either file is unparseable

_update_node_dependencies() calls the helper before each install and prints \"✓ {label} (already in sync with lockfile)\" when skipping. _tui_need_npm_install is now a thin wrapper around the helper plus its existing @hermes/ink presence guard — all 9 of its existing tests still pass unchanged, so the launcher's behaviour is preserved.

Test plan

  • Focused regressiontests/hermes_cli/test_update_node_dependencies_skip.py covers:
    • In-sync match (skip)
    • Missing marker / missing lockfile (run install)
    • Required package missing from marker (run install)
    • Optional/peer-only divergence (skip)
    • npm runtime annotations ignored (skip)
    • Version mismatch (run install)
    • Unparseable JSON → mtime fallback (newer marker = skip; newer lock = install)
    • End-to-end: _update_node_dependencies does NOT call _run_npm_install_deterministic when in sync; DOES call it when marker missing or packages diverge
  • Adjacent suitetests/hermes_cli/test_tui_npm_install.py (refactored function, all 9 tests pass unchanged); tests/hermes_cli/test_cmd_update.py::test_update_refreshes_repo_and_tui_node_dependencies updated to patch _npm_install_in_sync so the npm-call assertion runs regardless of whether the dev machine's node_modules happens to already match
  • Regression guard — removing the _npm_install_in_sync short-circuit from _update_node_dependencies makes test_update_skips_npm_call_when_in_sync fail with the expected "npm install failed in repo root" output instead of "already in sync with lockfile". Restoring the fix flips it back to passing.

Contract Protected

Aspect Behaviour
Both lockfiles match (required entries) Skip npm ci, print (already in sync with lockfile)
Hidden lockfile missing (fresh checkout) Run install — node_modules was never populated
Committed lockfile missing Run install — defensive default; _run_npm_install_deterministic falls back to npm install
Required package missing from hidden lock Run install
Required package version differs Run install
Only optional/peer packages differ Skip — npm intentionally skips these per platform
Either lockfile unparseable Fall back to mtime: install only if committed lockfile is newer

Fixes #17268

…ousResearch#17268)

`hermes update` ran `npm ci` unconditionally in the repo root and `ui-tui/`
on every run. `npm ci` deletes `node_modules` and re-installs from scratch
even when nothing has changed — the slow path on metered or restricted
networks (the issue reports 30–60s+ stalls or hangs on a typical update).

Extract the lockfile-content comparison from `_tui_need_npm_install` into
a standalone `_npm_install_in_sync(root)` helper, then call it from
`_update_node_dependencies` to short-circuit when `package-lock.json` and
`node_modules/.package-lock.json` already record the same package tree.
The comparison is the same one the TUI launcher has been using for
months — content-based, not mtime-based, so spurious lockfile timestamp
bumps from git checkouts don't trigger a reinstall.

The logic only ever skips when both lockfiles exist and agree on every
required (non-optional, non-peer) entry; missing lockfiles, missing
hidden lockfile (= node_modules never populated), or any divergence
falls back to the existing `_run_npm_install_deterministic` path.

`_tui_need_npm_install` is now a thin wrapper around the helper plus its
existing `@hermes/ink` presence guard; all of its existing tests still
pass unchanged.

Test plan
- Focused regression: `tests/hermes_cli/test_update_node_dependencies_skip.py`
  covers in-sync, missing marker, missing lockfile, package divergence,
  optional/peer-only divergence, version differences, and the mtime
  fallback for unparseable JSON, plus a regression test that
  `_update_node_dependencies` does not invoke `_run_npm_install_deterministic`
  when in sync.
- Adjacent suite: `tests/hermes_cli/test_tui_npm_install.py` (refactored
  function, all 9 tests pass), `tests/hermes_cli/test_cmd_update.py`
  (updated `test_update_refreshes_repo_and_tui_node_dependencies` to
  patch `_npm_install_in_sync` so the install path is exercised
  regardless of the dev machine's actual `node_modules` state).
- Regression guard: removing the `_npm_install_in_sync` skip from
  `_update_node_dependencies` makes
  `test_update_skips_npm_call_when_in_sync` fail with the expected
  "npm install failed" output instead of "already in sync with lockfile";
  restoring the fix flips it back to passing.

Fixes NousResearch#17268

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 29, 2026 10:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes hermes update by avoiding unnecessary npm ci runs when the installed node_modules already matches the committed package-lock.json, reducing update latency and avoiding destructive reinstalls on no-op updates.

Changes:

  • Added _npm_install_in_sync(root) to compare package-lock.json with node_modules/.package-lock.json and skip npm installs when already in sync.
  • Updated _update_node_dependencies() and _tui_need_npm_install() to use the new sync check and emit a “already in sync with lockfile” message when skipping.
  • Added a focused regression test suite for the sync-check logic and updated cmd_update tests to be deterministic regardless of local dev machine state.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
hermes_cli/main.py Introduces _npm_install_in_sync and uses it to short-circuit npm ci during updates / TUI startup when dependencies are already up to date.
tests/hermes_cli/test_update_node_dependencies_skip.py Adds new tests covering lockfile/marker comparisons and end-to-end skipping behavior in _update_node_dependencies().
tests/hermes_cli/test_cmd_update.py Patches _npm_install_in_sync in one test to force the install path and keep assertions stable across environments.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/main.py
Comment on lines 865 to 868
try:
wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wanted = json.loads(...).get("packages") or {} (and same for installed) assumes the packages value is a dict. If the JSON is parseable but packages is a non-dict (e.g., corrupted lockfile), the subsequent wanted.items() will raise AttributeError and crash hermes update. Please validate packages is a dict (otherwise treat as out-of-sync or fall back to the mtime heuristic) before iterating.

Suggested change
try:
wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
def read_packages(path: Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("lockfile root must be a JSON object")
packages = data.get("packages")
if packages is None:
return {}
if not isinstance(packages, dict):
raise ValueError("lockfile packages must be a JSON object")
return packages
try:
wanted = read_packages(lock)
installed = read_packages(marker)
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):

Copilot uses AI. Check for mistakes.
@alt-glitch alt-glitch added type/perf Performance improvement or optimization P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard labels Apr 29, 2026
…hapes

Copilot's review on NousResearch#17386 pointed out that
`json.loads(...).get("packages") or {}` assumes the JSON document is a
dict and that `packages` is a dict. A corrupted lockfile (top-level array,
or `packages` containing a list/string) would parse cleanly but then crash
`hermes update` at `wanted.items()` with `AttributeError`, since the
existing `try/except` only catches `OSError` / `UnicodeDecodeError` /
`json.JSONDecodeError`.

Validate both the parsed document and the `packages` map are dicts before
iterating; if either is the wrong shape, fall back to the mtime heuristic
that already handles the unparseable-JSON path. Behaviour for well-formed
lockfiles is unchanged.

Two new regression tests cover:
- `packages` is a list (lock-side malformed, marker fine, marker newer →
  treated as in sync via mtime)
- top-level JSON document is a list (lock-side malformed, lock newer →
  treated as out of sync via mtime)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot Finding addressed in commit 9062e79fb:

Finding (line 868 — wanted.items() crashes if packages is non-dict): Real concern. The original _tui_need_npm_install body had the same bug, but a crash there was non-fatal (TUI launch); calling it from _update_node_dependencies makes a corrupted lockfile fatal to hermes update.

Fix: Validate both the parsed JSON document and the packages map are dict before iterating; if either has the wrong shape, fall back to the existing mtime heuristic that already handles unparseable JSON. Two new regression tests cover the cases:

  • test_falls_back_to_mtime_when_packages_is_not_a_dictpackages is a list; with marker newer than lock, returns True (in sync) via mtime.
  • test_falls_back_to_mtime_when_top_level_is_not_an_object — top-level JSON is a list; with lock newer than marker, returns False (out of sync) via mtime.

All 27 tests in the new file + tests/hermes_cli/test_tui_npm_install.py + tests/hermes_cli/test_cmd_update.py pass.


CI audit: All 35 test job failures on commit 96607ed5b are pre-existing baselines on clean origin/main (58a6171bf). Zero failures intersect touched code (hermes_cli/main.py::_npm_install_in_sync, _update_node_dependencies, _tui_need_npm_install wrapper, or the new test file).

Test Symptom Baseline?
tests/agent/test_anthropic_adapter.py::TestBuildAnthropicClient::test_custom_base_url beta-header order regression ✅ confirmed locally on origin/main
tests/hermes_cli/test_update_autostash.py::test_cmd_update_succeeds_with_extras 'SimpleNamespace' has no attribute 'stdout' ✅ confirmed locally on origin/main (verified by stashing this branch)
tests/hermes_cli/test_update_autostash.py::test_cmd_update_retries_optional_extras_individually_when_all_fails same 'SimpleNamespace' has no attribute 'stdout' ✅ confirmed locally on origin/main
tests/agent/test_copilot_acp_client.py, tests/gateway/test_run_progress_topics.py, tests/hermes_cli/test_config_env_expansion.py, tests/hermes_cli/test_container_aware_cli.py, tests/hermes_cli/test_plugin_scanner_recursion.py, tests/hermes_cli/test_provider_config_validation.py (×2), tests/hermes_cli/test_gemini_provider.py, tests/hermes_cli/test_pty_bridge.py, tests/hermes_cli/test_web_server.py (×3), tests/run_agent/test_background_review_toolset_restriction.py, tests/gateway/test_gateway_shutdown.py, tests/test_tui_gateway_server.py, tests/gateway/test_session_split_brain_11016.py (×3), tests/tools/test_browser_orphan_reaper.py, tests/tools/test_clipboard.py (×3), tests/tools/test_mcp_dynamic_discovery.py, tests/tools/test_mcp_structured_content.py (×5), tests/tui_gateway/test_protocol.py, tests/tools/test_docker_environment.py various — assertion / TypeError / TimeoutError ✅ none touch hermes_cli/main.py's update path or the npm helpers

Touched code (_npm_install_in_sync, _update_node_dependencies, _tui_need_npm_install) is fully covered by 27 passing tests in tests/hermes_cli/test_update_node_dependencies_skip.py (18 cases) and tests/hermes_cli/test_tui_npm_install.py (9 cases).

@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 35 test job failures + 1 collection error on commit 9062e79fb are pre-existing baselines on clean origin/main (5a61c116e, run 25104603281). Zero failures intersect with touched code (hermes_cli/main.py::_update_node_dependencies, _tui_need_npm_install).

Touched-code regression check: focused tests pass — tests/hermes_cli/test_update_node_install_skip.py.

Same baseline cluster visible on neighboring open PRs (#17569, #17441, #17348). Notable inclusions:

  • test_credential_sources_registry_has_expected_steps — minimax-oauth provider not added to test fixture (commit 9eb16025b); covered by open PR fix: restore CI compatibility regressions #17334.
  • test_session.py collection error — normalize_whatsapp_identifier removed from gateway.session; covered by fix: restore CI compatibility regressions #17334's compat re-export.
  • test_mcp_structured_content (5) — _rpc_lock AttributeError on SimpleNamespace stub; covered by fix: restore CI compatibility regressions #17334.
  • test_clipboard.py::TestIsWsl (3) — Linux-only xdist ordering on _is_wsl() cache; passes in isolation locally.
  • test_modal_sandbox_fixes (2), test_setup vercel — xdist state pollution; pass in isolation.
  • test_protocol::test_session_resume_returns_hydrated_messages — DB-stub missing include_ancestors; my own merged PRs in this area also resurfaced.
  • test_anthropic_adapter::test_custom_base_url — model-alias drift (claude-sonnet-2025-08-07 vs claude-haiku-2025-05-14).

The npm ci skip-when-lockfile-matches change touches no test-affected code paths.

@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue clean — happy to reopen if this is still useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Skip npm install during updates when Node dependencies haven't changed

3 participants