Hermes Desktop Client Stuck in a Boot Loop After Update: “Backend Exited (0)” Explained and Fixed
TL;DR: After an auto-update, the Hermes desktop app (an Electron shell that spawns a Python backend from a venv) got stuck in an infinite restart loop with the log message Hermes backend exited (0). Exit code 0 means the backend shut itself down cleanly — it wasn’t a crash, it was a self-check failing silently. The root cause turned out to be two separate issues layered on top of each other: (1) a broken update path that synced the Python code but skipped pip install, and (2) a version mismatch between the Electron shell and the backend after that same update. The first was fixed with pip install -e .; the second required a full reinstall of the desktop app. This post walks through the full diagnostic process so you can shortcut it if you hit the same error.
The Symptom
After an update, the Hermes desktop client (an Electron-based app that launches a Python backend from a virtual environment) refused to finish booting. The log file at:
C:\Users\<user>\AppData\Local\hermes\logs\desktop.log
was flooded with a repeating cycle that looked like this:
[boot] Resolving Hermes backend
[boot] Resolving Hermes runtime
[bootstrap] Active Hermes runtime at ...\hermes-agent is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.
[boot] Hermes runtime is ready
[backend] `serve` supported for Hermes at ...\hermes-agent (venv: ...\hermes-agent\venv)
[boot] Starting Hermes backend via Hermes at ...\hermes-agent (venv: ...\hermes-agent\venv)
[boot] Waiting for Hermes backend to launch
Hermes backend exited (0)
[boot] Hermes backend exited before it became ready (0).
[boot] Desktop boot failed: Hermes backend exited before it became ready (0). Log: ...\desktop.log
The renderer would issue a reset requested by renderer, the app would restart the connection, and the whole cycle would repeat. Occasionally an internal self-healing routine kicked in:
[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure
(attempt=1/3, primaryBackendAlive=false, hardReinstall=false):
repair attempt 1/3: primary backend process has exited; restarting before escalating to reinstall
…but it never progressed past attempt=1/3 to an actual hardReinstall.
Why exit code 0 is a red herring (and a clue)
The single most important thing to understand about this failure mode: the Electron main process only logs the child process’s exit code — it does not surface the backend’s own stdout/stderr. An exit code of 0 means the process terminated cleanly, not that it crashed. In practice, this almost always means the backend ran, hit some internal precondition check (a version check, a config check, a lock check), decided not to proceed, and called sys.exit(0) on its own terms.
If you’re debugging any Electron-app-plus-subprocess architecture and see a clean exit code paired with “never became ready,” stop looking for a stack trace in the wrapper log — there isn’t one. You need to go one layer deeper.
Step 1: Digging the Real Error Out of the Full Log
Pulling the complete desktop.log (not just the tail the renderer displays) and grepping backward through it surfaced the missing context. Right before the failures started, there was an update event:
[updates] restart: Updating Hermes — this window will close and the updater will open...
[updates] venv shim unlocked; safe to proceed
[updates] update in progress (update-in-flight); deferring backend start until it finishes
[updates] launched repo hand-off script: ...\hermes-agent\scripts\desktop-update.ps1 (branch main); exiting desktop to release venv shim
[updates] update finished; proceeding with backend start
[updates] detached update finished OK (branch main)
Comparing this against earlier, successful updates in the same log revealed something important — previous updates had gone through a different code path entirely:
[updates] launched updater: ...\hermes-setup.exe --update --branch main; exiting desktop to release venv shim
In other words, this particular update used a different update mechanism — a “repo hand-off” PowerShell script instead of the usual full installer. And from that exact update onward, Hermes backend exited (0) went from an occasional blip to a 100%-reproducible failure on every single launch attempt.
Step 2: Testing the Dependency Hypothesis
Hypothesis #1: The “repo hand-off” script likely just synced the hermes-agent source code (something like a git pull) without re-running pip install. If the backend’s code was updated but its installed dependencies inside the venv weren’t, any version check on startup would fail and the process would bail out — cleanly, with exit code 0.
This is trivially easy to test: activate the venv and run the backend by hand, so any error prints directly to the terminal instead of being swallowed by Electron.
cd C:\Users\<user>\AppData\Local\hermes\hermes-agent
venv\Scripts\activate
pip install -e .
hermes serve
Result:
HERMES_BACKEND_READY port=9119
Hermes backend listening on 127.0.0.1:9119
It worked. This confirmed the update script had indeed skipped the dependency sync step — running pip install -e . by hand fixed it, and the backend started and bound to a port with no errors at all.
Step 3: The Fix That Wasn’t Quite the Fix
The dependency issue looked solved, but the story wasn’t over. After the fix, and after fully closing the manual test terminal to rule out a stale process holding a port, the desktop client was relaunched — and it failed exactly the same way, every time.
At this point most of the usual suspects were eliminated:
The only remaining variable: the desktop client launches the backend differently than a manually typed hermes serve command does.
A clue was buried in an earlier successful session’s event payload — the backend reports a field back to the shell on every session:
"desktop_contract": 2
This strongly implies a version-handshake protocol between the Electron shell and the Python backend. And the “repo hand-off” update script only touched hermes-agent (the Python side) — it never touched the Electron shell itself (app.asar, the win-unpacked directory, etc.), which is normally updated via the full hermes-setup.exe installer.
Put together, the picture is:
-
Backend code: updated to the latest version, expecting a new desktop_contract -
Electron shell: still on the old version, speaking the old protocol
When the shell launches the backend in “desktop mode,” it performs this handshake/contract check, the check fails, and the backend exits cleanly (exit 0) rather than crashing. A manually invoked hermes serve skips this handshake entirely, which is exactly why it worked standalone but failed under the desktop client — a partial fix at one layer doesn’t guarantee the whole pipeline works, especially when there’s a version contract between two independently-updatable components.
The Fix: Reinstall the Shell, Not Just the Backend
Once the update mechanism itself was the suspect rather than any specific file, the fix stopped being about patching individual pieces and became about re-aligning the shell and backend to the same version:
-
Fully quit Hermes, including the background tray icon. -
Skip the in-app “Check for Updates” — download the latest full installer directly from the official release page instead. -
Run the installer to do a clean, full reinstall (not an in-place patch). -
Relaunch the client.
This resolved the issue completely.
Root Cause Summary
Two independent bugs stacked on top of each other, which is why this took a couple of passes to fully diagnose:
-
A newer update mechanism (“repo hand-off” via a PowerShell script) synced the backend’s source code but never called pip install, leaving stale dependencies in the venv. -
That same update mechanism only updates the Python backend, not the Electron shell — so shell and backend can drift out of sync on their internal version-handshake protocol, causing the shell to refuse to finish booting the backend even once the dependency issue is fixed.
Key Takeaways for Debugging Similar Electron + Subprocess Backend Failures
If you’re troubleshooting a desktop app that wraps a subprocess backend (a very common architecture for Electron + Python/Node backend apps), these lessons generalize well beyond Hermes specifically:
-
Exit code 0 is not “no problem” — it’s often “the program decided to stop on purpose.” For any long-running service or daemon, a clean exit combined with “never became ready” usually points to an internal precondition check, not a crash. Look for version checks, config validation, or lock-file logic rather than searching for a stack trace. -
Wrapper/shell logs rarely capture the child process’s real error output. The most reliable debugging step is bypassing the shell entirely and running the exact same command it invokes, by hand, in a terminal — errors that get silently swallowed by the parent process will print normally on their own. -
When a problem starts right after an auto-update, check which update mechanism actually ran. Systems with more than one update path (e.g., a full installer vs. a lightweight code-sync script) are prone to this exact class of bug: one path updates everything, the other only updates part of the system, and users can’t tell which one just ran unless they read the logs carefully. -
Fixing one layer doesn’t validate the whole pipeline. After patching the dependency issue by hand, it was tempting to call the bug fixed — but the fix needs to be re-verified through the actual trigger path (the desktop client itself), not just the manual command used to diagnose it. A “partial fix” can look identical to “no fix” from the user’s perspective if you only test the wrong layer.
Frequently Asked Questions
Q: What does “Hermes backend exited (0)” actually mean?
Exit code 0 signals a normal, intentional process termination — not a crash. It typically means the backend process ran, evaluated some internal condition (dependency version, config validity, a version handshake with its parent process), and chose to exit gracefully rather than continue. It’s the opposite of what most people assume when they see a “crash” in the logs.
Q: I fixed my Python dependencies but the desktop app still fails to start — why?
If the backend runs successfully when launched manually (e.g., via hermes serve in an activated venv) but still fails when launched by the desktop app itself, the two launch paths are not equivalent. Look for a version-handshake or contract check between the app shell and the backend — an update that only patches one side of that contract (e.g., backend code but not the shell binary) can leave them permanently out of sync until a full reinstall.
Q: How do I get the real error message instead of just an exit code?
Bypass the parent application and run the underlying command directly in a terminal, using the exact same working directory and virtual environment the app would use. Wrapper processes (especially Electron apps) typically only forward the exit code of a child process, not its stdout/stderr, so the real error text is often only visible when you run the command yourself.
Q: Why did a “repo hand-off” update break things when previous updates worked fine?
Because it was a different, less complete update path than the standard installer. Some apps support multiple update mechanisms — a full installer that replaces every component, and a lighter-weight script that only syncs source code for faster iteration. If the lightweight path skips steps the full installer normally performs (like reinstalling dependencies or updating the app shell itself), it can leave the system in an inconsistent, partially-updated state that’s hard to diagnose from the surface-level error alone.

