From 950b55d4d7fe60071a873c01fef450e81c750b71 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 5 Aug 2026 10:34:18 -0600 Subject: [PATCH] feat(update): emit an action-scoped terminal receipt from hermes update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ===` 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 Co-authored-by: doncazper --- hermes_cli/main.py | 1 + hermes_cli/update_cmd.py | 17 ++++- hermes_cli/web_server.py | 38 ++++++++++- .../test_update_hangup_protection.py | 20 ++++++ tests/hermes_cli/test_web_server.py | 66 +++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index da85c436bd4c7..e6612636da75e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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, diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index c9069b1b1a227..c99d56a81e18b 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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 — diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1fb3e6131629e..3627f34a0e1c1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 `` 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, diff --git a/tests/hermes_cli/test_update_hangup_protection.py b/tests/hermes_cli/test_update_hangup_protection.py index e467830b7e48b..0171c07dbe86b 100644 --- a/tests/hermes_cli/test_update_hangup_protection.py +++ b/tests/hermes_cli/test_update_hangup_protection.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4c0b5500f172a..462e8881f8e8c 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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, + } +