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:
parent
c8648278c3
commit
950b55d4d7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue