Skip to content

fix(model-router): stop/recover router on uninstall & onboard; extend gateway health wait - #5230

Closed
tyeth-ai-assisted wants to merge 4 commits into
NVIDIA:mainfrom
tyeth-ai-assisted:fix/model-router-uninstall-recover
Closed

fix(model-router): stop/recover router on uninstall & onboard; extend gateway health wait#5230
tyeth-ai-assisted wants to merge 4 commits into
NVIDIA:mainfrom
tyeth-ai-assisted:fix/model-router-uninstall-recover

Conversation

@tyeth-ai-assisted

@tyeth-ai-assisted tyeth-ai-assisted commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the model-router failing to stop/recover during uninstall and on fresh onboard, plus a longer Hermes gateway health wait. Three clean commits on top of upstream main.

Commits

  • fix(uninstall): stop model router and extend Hermes gateway health wait — uninstall left a running model-router on port 4000, causing reinstall to fail with "Port 4000 already has a healthy router endpoint, but its credential state is unknown."
  • fix(model-router): auto-recover orphaned router on fresh onboard session
  • fix(model-router): detect Python-interpreted model-router in /proc and ps (uninstall doesn't remove model-router #5169)

Testing

  • ⚠️ Run npm install && npm test && make check under Node 22 before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Hermes gateway: longer startup retry window and automatic API server key creation if missing
    • Uninstall now stops lingering model-router processes as part of service shutdown
    • Onboarding improved to better detect and reconcile orphaned model-router instances
  • Tests

    • Added thorough tests for model-router detection and uninstall cleanup
    • Updated uninstall plan tests to include the new stop-model-router step

tyeth and others added 3 commits June 11, 2026 13:10
Fixes NVIDIA#5169: uninstall.sh left a running model-router process on port
4000, causing reinstall to fail with "Port 4000 already has a healthy
router endpoint, but its credential state is unknown."

Changes:
- Add stop-model-router action to the Stopping services uninstall step;
  reads routerPid from onboard-session.json and falls back to lsof :4000
  scan, mirroring the Ollama auth proxy stop pattern (NVIDIA#2759)
- Raise wait_for_hermes_gateway_internal attempts from 45 to 60 in
  agents/hermes/start.sh so the gateway health wait budget covers ~120s
  of effective curl timeout (was ~90s) on slow WSL2 hosts

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
When onboarding starts fresh (no recorded routerPid) but a model-router
process is already healthy on port 4000 from a previous failed install,
reconcileModelRouter now scans /proc to find and stop the orphan before
starting a new router — instead of throwing "credential state is unknown"
and requiring a manual stop-and-retry. See issue NVIDIA#5169.

Adds findModelRouterPidForPort to model-router-process.ts with injectable
deps (listProcPids, readProcCommandLine) for unit testing, guarded by the
existing isModelRouterCommandLineForPort check so non-router services on
the same port are never killed.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…d ps (NVIDIA#5169)

model-router runs as a Python venv entry-point script, so the OS interposes
the interpreter: /proc cmdline is 'python /path/model-router proxy --port …'
with args[0]=python and args[1]=model-router. The previous isModelRouterCommandLineForPort
check only tested args[0], causing both the uninstall orphan-stop and the
onboard orphan-recovery to silently skip the running process.

Fix:
- isModelRouterCommandLineForPort: check args[0] OR args[1] basename === model-router
- isModelRouterPid (uninstall run-plan): same dual-position check via ps
- Update all relevant tests to use the realistic Python-interpreter cmdline
  (python /path/model-router proxy --port 4000) to exercise the fixed path

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 273fab46-9a46-4af2-8ba2-d2d80259abc8

📥 Commits

Reviewing files that changed from the base of the PR and between 78f2415 and 9699ae0.

📒 Files selected for processing (3)
  • src/lib/actions/uninstall/run-plan.test.ts
  • src/lib/actions/uninstall/run-plan.ts
  • src/lib/onboard/model-router.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/lib/onboard/model-router.ts
  • src/lib/actions/uninstall/run-plan.test.ts
  • src/lib/actions/uninstall/run-plan.ts

📝 Walkthrough

Walkthrough

Model-router detection and shutdown added to uninstall and onboarding reconciliation; Hermes startup now ensures an API_SERVER_KEY in its .env and increases gateway health-probe retries.

Changes

Model Router Lifecycle Management

Layer / File(s) Summary
Model Router PID Detection
src/lib/onboard/model-router-process.ts, src/lib/onboard/model-router-process.test.ts
Adds injectable listProcPids, broadens isModelRouterCommandLineForPort to detect interpreter-interposed invocations, and adds findModelRouterPidForPort to scan /proc and return the first matching PID for a port. Unit tests cover direct, Python-wrapped, empty/missing, non-integer entries, and multiple-candidate cases.
Uninstall Plan Domain
src/lib/domain/uninstall/plan.ts, src/lib/domain/uninstall/plan.test.ts
Adds { kind: "stop-model-router" } to UninstallPlanAction and includes it in the "Stopping services" step; tests updated to expect the new action.
Uninstall Execution
src/lib/actions/uninstall/run-plan.ts, src/lib/actions/uninstall/run-plan.test.ts
Implements stopModelRouter that prefers the session-recorded PID (with ownership/cmdline validation) and falls back to lsof -ti :4000, sending SIGTERM then SIGKILL on timeout. Wired into the uninstall plan after stopOllamaAuthProxy. Tests verify session-based kill, port-based fallback, ownership gating, cmdline gating, and logging.
Onboarding Router Reconciliation
src/lib/onboard/model-router.ts
reconcileModelRouter now attempts to find and stop an orphaned model-router proxy via findModelRouterPidForPort when the recorded PID doesn't own a reachable router port before failing.

Hermes Gateway API Server Key Injection

Layer / File(s) Summary
Key Generation and Injection
agents/hermes/start.sh
Adds ensure_hermes_api_server_key to validate symlink safety, generate and append API_SERVER_KEY to HERMES_DIR/.env when missing, adjust ownership/permissions for root vs non-root, and refresh SHA-256 hash outputs. Also increases the Hermes gateway health-check retry upper bound.
Startup Integration
agents/hermes/start.sh
Invokes ensure_hermes_api_server_key in both non-root and root startup branches prior to provider placeholder refresh and service startup.

Sequence Diagram(s)

sequenceDiagram
  participant ComponentA
  participant ComponentB
  ComponentA->>ComponentB: observable interaction
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

area: onboarding

Suggested reviewers

  • cv

Poem

🐇 A router stops, a key appears,
Hermes wakes without our fears,
Scripts that search and gently pry,
Find the pid or let it lie,
Keys in place, the startup cheers!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: stopping the model-router on uninstall, recovering it during onboard, and extending Hermes gateway health wait timeout.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
agents/hermes/start.sh (1)

676-691: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Accept 401 as a healthy Hermes gateway response.

The loop still uses curl -sf, so a live gateway that returns 401 from /health will burn through all 60 retries and abort startup. src/lib/verify-deployment.ts already treats 200 and 401 as alive for this endpoint, so the retry increase does not fix that false-negative path.

Suggested fix
 wait_for_hermes_gateway_internal() {
   local gateway_pid="$1"
   local attempts=0
+  local http_code
   while [ "$attempts" -lt 60 ]; do
-    if curl -sf --max-time 2 "http://127.0.0.1:${INTERNAL_PORT}/health" >/dev/null 2>&1; then
+    http_code="$(
+      curl -so /dev/null -w '%{http_code}' --max-time 2 \
+        "http://127.0.0.1:${INTERNAL_PORT}/health" 2>/dev/null || echo 000
+    )"
+    if [ "$http_code" = "200" ] || [ "$http_code" = "401" ]; then
       return 0
     fi
     if ! kill -0 "$gateway_pid" 2>/dev/null; then
       wait "$gateway_pid"
       return $?

As per coding guidelines, Hermes health-wait changes should stay aligned with the nightly E2E expectations, and the downstream gateway probe already counts 401 as alive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agents/hermes/start.sh` around lines 676 - 691, The health check in function
wait_for_hermes_gateway_internal incorrectly treats any 4xx as failure because
it uses curl -sf; change the check to capture the HTTP status (e.g., use curl -s
--max-time 2 -o /dev/null -w '%{http_code}'
"http://127.0.0.1:${INTERNAL_PORT}/health") and consider the gateway healthy if
the returned status is 200 or 401; keep the existing kill -0/gateway_pid logic
and return behavior unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agents/hermes/start.sh`:
- Around line 972-1003: Record the initial ownership/mode of the .env (and
compat_hash/hash_file if relevant) before you mutate them and restore that
original state at the end instead of always chowning/chmodding to sandbox:
sandbox and 640; specifically, in the start.sh block that writes API_SERVER_KEY
(variables env_file, hash_file, compat_hash, HERMES_DIR), capture the original
owner/group and permission bits into local vars (e.g., original_owner,
original_group, original_mode) before any chown/chmod, and when the script
finishes the sha256/write section restore those saved values (use chown/chmod to
the saved owner/group/mode) so that hermes_config_root_is_locked() and
restore_hermes_config_permissions_after_dashboard_start() see the preserved
locked posture rather than always reverting to sandbox:640.

In `@src/lib/onboard/model-router-process.test.ts`:
- Around line 57-68: The test title and implementation diverge: "skips
non-integer entries from the PID list" currently uses an empty list and only
checks that readProcCommandLine isn't called. Update the test for
findModelRouterPidForPort to supply a mixed PID array (e.g., include non-numeric
values like NaN/"abc"/Infinity alongside a valid PID like 100) via listProcPids
and have readProcCommandLine record which PIDs were invoked and return a
matching command line for the valid PID; then assert the function returns the
valid PID and that only the valid finite PID(s) were passed to
readProcCommandLine, thereby exercising the Number->isFinite filtering in
model-router-process.ts.

---

Outside diff comments:
In `@agents/hermes/start.sh`:
- Around line 676-691: The health check in function
wait_for_hermes_gateway_internal incorrectly treats any 4xx as failure because
it uses curl -sf; change the check to capture the HTTP status (e.g., use curl -s
--max-time 2 -o /dev/null -w '%{http_code}'
"http://127.0.0.1:${INTERNAL_PORT}/health") and consider the gateway healthy if
the returned status is 200 or 401; keep the existing kill -0/gateway_pid logic
and return behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 62e571bf-d881-437c-a154-d4db814b776f

📥 Commits

Reviewing files that changed from the base of the PR and between 6622476 and 78f2415.

📒 Files selected for processing (8)
  • agents/hermes/start.sh
  • src/lib/actions/uninstall/run-plan.test.ts
  • src/lib/actions/uninstall/run-plan.ts
  • src/lib/domain/uninstall/plan.test.ts
  • src/lib/domain/uninstall/plan.ts
  • src/lib/onboard/model-router-process.test.ts
  • src/lib/onboard/model-router-process.ts
  • src/lib/onboard/model-router.ts

Comment thread agents/hermes/start.sh
Comment on lines +972 to +1003
if [ "$(id -u)" -eq 0 ]; then
chown root:sandbox "$env_file" || return 1
chmod 640 "$env_file" || return 1
chmod u+w "$hash_file" || return 1
[ ! -f "$compat_hash" ] || chmod u+w "$compat_hash" 2>/dev/null || true
elif [ ! -w "$env_file" ]; then
echo "[config] Cannot inject API_SERVER_KEY — .env not writable (non-root mode); Hermes api_server will fail" >&2
return 0
fi

local new_key
new_key=$(python3 -c "import secrets; print(secrets.token_hex(32))")
printf 'API_SERVER_KEY=%s\n' "$new_key" >>"$env_file"
echo "[config] Generated missing API_SERVER_KEY for Hermes API server (Hermes v0.16.0+ requirement)" >&2

local _write_rc=0
if sha256sum "${HERMES_DIR}/config.yaml" "${HERMES_DIR}/.env" >"$hash_file"; then
chown root:root "$hash_file" 2>/dev/null || true
chmod 444 "$hash_file" 2>/dev/null || true
if [ -f "$compat_hash" ]; then
sha256sum "${HERMES_DIR}/config.yaml" "${HERMES_DIR}/.env" >"$compat_hash" || _write_rc=$?
chown sandbox:sandbox "$compat_hash" 2>/dev/null || true
chmod 600 "$compat_hash" 2>/dev/null || true
fi
else
_write_rc=$?
fi

if [ "$(id -u)" -eq 0 ]; then
chown sandbox:sandbox "$env_file" 2>/dev/null || true
chmod 640 "$env_file" 2>/dev/null || true
fi

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the shields-up lock state when rewriting .env.

If .env starts in the locked posture (root:root and non-writable), this helper always restores it to sandbox:sandbox 640. That makes hermes_config_root_is_locked() go false and restore_hermes_config_permissions_after_dashboard_start() then downgrades the whole config root back to mutable 3770, so a post-upgrade boot silently disables shields-up on previously locked sandboxes.

Suggested fix
 ensure_hermes_api_server_key() {
   local env_file="${HERMES_DIR}/.env"
   local hash_file="${HERMES_HASH_FILE}"
   local compat_hash="${HERMES_DIR}/.config-hash"
+  local env_was_locked=0
   [ -f "$env_file" ] || return 0

+  if hermes_config_path_is_locked "$env_file"; then
+    env_was_locked=1
+  fi
+
   grep -q "^API_SERVER_KEY=" "$env_file" 2>/dev/null && return 0
@@
   if [ "$(id -u)" -eq 0 ]; then
-    chown sandbox:sandbox "$env_file" 2>/dev/null || true
-    chmod 640 "$env_file" 2>/dev/null || true
+    if [ "$env_was_locked" -eq 1 ]; then
+      chown root:root "$env_file" 2>/dev/null || true
+      chmod 444 "$env_file" 2>/dev/null || true
+    else
+      chown sandbox:sandbox "$env_file" 2>/dev/null || true
+      chmod 640 "$env_file" 2>/dev/null || true
+    fi
   fi

Based on learnings, the normal 640 sandbox:sandbox mutable posture is intentional here, but the locked integrity posture is supposed to remain distinct and preserved across startup mutations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agents/hermes/start.sh` around lines 972 - 1003, Record the initial
ownership/mode of the .env (and compat_hash/hash_file if relevant) before you
mutate them and restore that original state at the end instead of always
chowning/chmodding to sandbox: sandbox and 640; specifically, in the start.sh
block that writes API_SERVER_KEY (variables env_file, hash_file, compat_hash,
HERMES_DIR), capture the original owner/group and permission bits into local
vars (e.g., original_owner, original_group, original_mode) before any
chown/chmod, and when the script finishes the sha256/write section restore those
saved values (use chown/chmod to the saved owner/group/mode) so that
hermes_config_root_is_locked() and
restore_hermes_config_permissions_after_dashboard_start() see the preserved
locked posture rather than always reverting to sandbox:640.

Source: Learnings

Comment on lines +57 to +68
it("skips non-integer entries from the PID list", () => {
let called = false;
const pid = findModelRouterPidForPort(4000, {
readProcCommandLine: () => {
called = true;
return null;
},
listProcPids: () => [],
});
expect(pid).toBe(null);
expect(called).toBe(false);
});

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test name doesn't match implementation.

The test is named "skips non-integer entries from the PID list" but it provides an empty listProcPids: () => [] and verifies that readProcCommandLine is never called. This tests empty-list behavior, not non-integer filtering.

The production code's non-integer filtering happens at model-router-process.ts:159-162 where /proc entries are mapped to Number then filtered by isFinite. To properly test that logic, provide a mixed PID list like [1, NaN, 100] or ["abc", 100, "def"] and verify only valid PIDs are processed.

📝 Suggested test improvement
-  it("skips non-integer entries from the PID list", () => {
+  it("returns null when the PID list is empty", () => {
     let called = false;
     const pid = findModelRouterPidForPort(4000, {

Or add a separate test:

it("skips non-integer entries from the PID list", () => {
  const calledPids: number[] = [];
  const pid = findModelRouterPidForPort(4000, {
    readProcCommandLine: (p) => {
      calledPids.push(p);
      return p === 100
        ? ["/opt/model-router", "proxy", "--port", "4000"]
        : null;
    },
    listProcPids: () => [NaN, 100, Infinity, -1, 0] as any,
  });
  expect(pid).toBe(100);
  expect(calledPids).toEqual([100]); // Only the valid finite positive integer
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard/model-router-process.test.ts` around lines 57 - 68, The test
title and implementation diverge: "skips non-integer entries from the PID list"
currently uses an empty list and only checks that readProcCommandLine isn't
called. Update the test for findModelRouterPidForPort to supply a mixed PID
array (e.g., include non-numeric values like NaN/"abc"/Infinity alongside a
valid PID like 100) via listProcPids and have readProcCommandLine record which
PIDs were invoked and return a matching command line for the valid PID; then
assert the function returns the valid PID and that only the valid finite PID(s)
were passed to readProcCommandLine, thereby exercising the Number->isFinite
filtering in model-router-process.ts.

Thabhelo added a commit to Thabhelo/NemoClaw that referenced this pull request Jun 11, 2026
Uninstall and onboard both failed to stop routers started via the venv
interpreter (python /path/model-router proxy). Also auto-recover orphaned
routers during reconcileModelRouter when session PID is stale.

Incorporates NVIDIA#5169-relevant pieces from NVIDIA#5230; Hermes gateway changes
left out of scope.
@Thabhelo

Copy link
Copy Markdown
Contributor

Thanks @tyeth-ai-assisted — incorporated the #5169-relevant pieces (Python venv cmdline detection, findModelRouterPidForPort, onboard orphan recovery) into #5194.

Left the Hermes start.sh changes here since they're a separate concern. Happy for maintainers to pick whichever PR fits their merge preference.

@wscurran wscurran added area: cli Command line interface, flags, terminal UX, or output bug-fix PR fixes a bug or regression integration: hermes Hermes integration behavior labels Jun 12, 2026
@wscurran

Copy link
Copy Markdown
Contributor

✨ Thanks for addressing the model-router cleanup on uninstall and the orphaned router recovery during onboard. This proposes a way to stop the router on uninstall, auto-recover orphaned routers on fresh onboard, and extend the Hermes gateway health wait with three focused commits.


Related open issues:

@cv

cv commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

@tyeth-ai-assisted could you add a DCO to the PR body text, please?

@tyeth-ai-assisted

tyeth-ai-assisted commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

@tyeth-ai-assisted could you add a DCO to the PR body text, please?

@cv you still want this despite merging the alternative?

Edit - sorry my mobile app didn't update the issue content until I replied, DOH!

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

Labels

area: cli Command line interface, flags, terminal UX, or output bug-fix PR fixes a bug or regression integration: hermes Hermes integration behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants