feat(update): emit an action-scoped terminal receipt from hermes update

The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
This commit is contained in:
Brooklyn Nicholson 2026-08-05 10:34:18 -06:00
parent c8648278c3
commit 950b55d4d7
5 changed files with 136 additions and 6 deletions

View File

@ -5071,6 +5071,7 @@ from hermes_cli.update_cmd import ( # noqa: F401
_print_curator_recent_run_notice,
_print_fts_optimize_available_notice,
_print_stash_cleanup_guidance,
_print_update_completion,
_record_npm_lockfile_hash,
_refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies,

View File

@ -712,6 +712,16 @@ def _commit_staged_replacements(staged) -> None:
pass
def _print_update_completion(message: str) -> None:
"""Print an update outcome plus, when the dashboard launched this run
with an action id, a terminal receipt line the Desktop can match after
the dashboard restarts (see #47359 / #58764)."""
print(message)
action_id = os.environ.get("HERMES_ACTION_ID", "")
if len(action_id) == 32 and all(char in "0123456789abcdef" for char in action_id):
print(f"=== hermes-update completed {action_id} ===")
def _update_via_zip(args):
"""Update Hermes Agent by downloading a ZIP archive.
@ -1059,7 +1069,7 @@ def _update_via_zip(args):
print(" Code and Python deps are updated, but the dashboard/TUI may")
print(" be in a mixed state until the Node deps are rebuilt.")
else:
print("✓ Update complete!")
_print_update_completion("✓ Update complete!")
try:
_print_curator_first_run_notice()
except Exception as e:
@ -3916,11 +3926,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
healthy_after, detail_after = _venv_core_imports_healthy()
if healthy_after:
print("✓ Dependencies repaired!")
_print_update_completion("✓ Update complete!")
else:
print(f"⚠ Venv still unhealthy after repair: {detail_after}")
print(" Close all Hermes windows/gateways and re-run: hermes update")
else:
print("✓ Already up to date!")
_print_update_completion("✓ Already up to date!")
if runtime_repaired is not None and not _m()._is_windows():
print()
print(
@ -4591,7 +4602,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(" Code and Python deps are updated, but the dashboard/TUI may")
print(" be in a mixed state until the Node deps are rebuilt.")
else:
print("✓ Update complete!")
_print_update_completion("✓ Update complete!")
# Search-index optimization notice (v23). Existing installs keep their
# working search index untouched on update; the compact v23 layout —

View File

@ -3743,6 +3743,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
# report liveness and exit code without shelling out to ``ps``.
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
_ACTION_IDS: Dict[str, str] = {}
# ``name`` → completed synthetic action result for actions the server handled
# without spawning a subprocess (for example, unsupported Docker updates).
@ -3763,6 +3764,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
log_file.write(b"\n")
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
_ACTION_IDS.pop(name, None)
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
@ -3779,7 +3781,12 @@ def _dashboard_spawn_executable() -> str:
return sys.executable
def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
def _spawn_hermes_action(
subcommand: List[str],
name: str,
*,
env_overrides: Optional[Dict[str, str]] = None,
) -> subprocess.Popen:
"""Spawn ``hermes <subcommand>`` detached and record the Popen handle.
Uses the running interpreter's ``hermes_cli.main`` module so the action
@ -3808,7 +3815,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
"stdin": subprocess.DEVNULL,
"stdout": log_file,
"stderr": subprocess.STDOUT,
"env": action_env,
"env": {**action_env, **(env_overrides or {})},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = windows_detach_flags()
@ -3823,6 +3830,11 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
_ACTION_RESULTS.pop(name, None)
_ACTION_COMMANDS[name] = tuple(subcommand)
_ACTION_PROCS[name] = proc
action_id = (env_overrides or {}).get("HERMES_ACTION_ID")
if action_id:
_ACTION_IDS[name] = action_id
else:
_ACTION_IDS.pop(name, None)
return proc
@ -4114,8 +4126,26 @@ async def update_hermes():
"update_command": message,
}
existing = _ACTION_PROCS.get("hermes-update")
if existing is not None and existing.poll() is None:
response = {
"ok": True,
"pid": existing.pid,
"name": "hermes-update",
"already_running": True,
}
action_id = _ACTION_IDS.get("hermes-update")
if action_id:
response["action_id"] = action_id
return response
action_id = secrets.token_hex(16)
try:
proc = _spawn_hermes_action(["update"], "hermes-update")
proc = _spawn_hermes_action(
["update"],
"hermes-update",
env_overrides={"HERMES_ACTION_ID": action_id},
)
except Exception as exc:
_log.exception("Failed to spawn hermes update")
raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}")
@ -4123,6 +4153,7 @@ async def update_hermes():
"ok": True,
"pid": proc.pid,
"name": "hermes-update",
"action_id": action_id,
}
@ -4763,6 +4794,7 @@ async def get_action_status(name: str, lines: int = 200):
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
_ACTION_IDS.pop(name, None)
return {
"name": name,

View File

@ -19,10 +19,30 @@ from hermes_cli.main import (
_finalize_update_output,
_install_hangup_protection,
_log_only_write,
_print_update_completion,
_run_logged_subprocess,
)
def test_update_completion_includes_bounded_action_identity(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ACTION_ID", "a" * 32)
_print_update_completion("✓ Update complete!")
assert capsys.readouterr().out.splitlines() == [
"✓ Update complete!",
f"=== hermes-update completed {'a' * 32} ===",
]
def test_update_completion_rejects_untrusted_action_identity(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ACTION_ID", "not-safe\nforged")
_print_update_completion("✓ Update complete!")
assert capsys.readouterr().out == "✓ Update complete!\n"
# -----------------------------------------------------------------------------
# _UpdateOutputStream
# -----------------------------------------------------------------------------

View File

@ -1003,6 +1003,72 @@ class TestWebServerEndpoints:
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
def test_update_hermes_spawns_with_action_id(self, monkeypatch):
import hermes_cli.web_server as web_server
class Proc:
pid = 12345
calls = []
def fake_spawn(subcommand, name, *, env_overrides=None):
calls.append((subcommand, name, env_overrides))
return Proc()
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(web_server.secrets, "token_hex", lambda _size: "a" * 32)
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"pid": 12345,
"name": "hermes-update",
"action_id": "a" * 32,
}
assert calls == [
(["update"], "hermes-update", {"HERMES_ACTION_ID": "a" * 32})
]
def test_update_hermes_reuses_running_action(self, monkeypatch):
import hermes_cli.web_server as web_server
class Proc:
pid = 24680
def poll(self):
return None
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda *_args, **_kwargs: pytest.fail("must not spawn a duplicate update"),
)
web_server._ACTION_PROCS["hermes-update"] = Proc()
web_server._ACTION_IDS["hermes-update"] = "b" * 32
try:
resp = self.client.post("/api/hermes/update")
finally:
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_IDS.pop("hermes-update", None)
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"pid": 24680,
"name": "hermes-update",
"already_running": True,
"action_id": "b" * 32,
}