fix(serve): announce both ready tokens so a stale packaged parser still boots

A headless `hermes serve` emitted only HERMES_BACKEND_READY. A packaged
Desktop artifact whose readiness parser predates the neutral token
(the CLI-only `hermes update` path never rebuilds the packaged app)
matched nothing, then killed the healthy backend after the
port-announcement timeout — the artifact-skew signature of #60772.
Announce the neutral token first and the legacy HERMES_DASHBOARD_READY
one after it; current parsers match either, and stale packaged parsers
match the legacy line. The legacy `dashboard` backend keeps its token.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:46:05 -05:00
committed by brooklyn!
parent ce750ff151
commit 41de397af6
2 changed files with 55 additions and 5 deletions

View File

@@ -1353,11 +1353,18 @@ def _on_server_started(
_best_effort("host rendezvous publish", lambda: _publish_host_rendezvous(host, actual_port))
_write_dashboard_ready_file(actual_port)
# Port-discovery sentinel parsed by the Desktop spawn (matches either
# token). Written to fd 1: tui_gateway.server redirects sys.stdout to
# stderr at import, and the Desktop watches child.stdout (#96282).
ready_token = "HERMES_BACKEND_READY" if headless else "HERMES_DASHBOARD_READY"
_write_machine_sentinel_line(f"{ready_token} port={actual_port}")
# Port-discovery sentinel parsed by the Desktop spawn. Written to fd 1:
# tui_gateway.server redirects sys.stdout to stderr at import, and the
# Desktop watches child.stdout (#96282). A headless `serve` announces the
# neutral token FIRST and the legacy HERMES_DASHBOARD_READY one after it:
# a packaged Desktop artifact whose parser predates the neutral token
# (#60772) still matches the legacy sentinel, while current parsers match
# either. The legacy `dashboard` backend keeps its own single token.
if headless:
_write_machine_sentinel_line(f"HERMES_BACKEND_READY port={actual_port}")
_write_machine_sentinel_line(f"HERMES_DASHBOARD_READY port={actual_port}")
else:
_write_machine_sentinel_line(f"HERMES_DASHBOARD_READY port={actual_port}")
if headless:
# Auth-gated JSON-RPC/WS only — announce the bind, not a URL. flush:
# a piped stdout otherwise surfaces this minutes after the sentinel.

View File

@@ -216,3 +216,46 @@ def test_ready_sentinel_arrives_on_stdout_not_stderr(tmp_path):
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
def test_headless_serve_announces_both_ready_tokens(tmp_path):
"""#60772 — a headless ``serve`` must announce BOTH ready tokens.
The packaged Desktop artifact can be older than the Python backend (the
CLI-only ``hermes update`` path does not rebuild the packaged app). Its
readiness parser may still match only the legacy ``HERMES_DASHBOARD_READY``
line; a backend that announces only ``HERMES_BACKEND_READY`` then boots
healthily and gets killed after the port-announcement timeout — the exact
artifact-skew signature in #60772. The neutral token must come first so
current parsers match it before the legacy one.
"""
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
proc = _spawn_serve(port, tmp_path)
try:
# Read past the NEUTRAL token to the legacy one: _read_until stops at
# its token, so waiting for the legacy line proves BOTH were written
# (the neutral one precedes it) without a timing race.
ready, lines = _read_until(proc, f"HERMES_DASHBOARD_READY port={port}")
out = "".join(lines)
assert ready, (
f"legacy token missing (a stale packaged Desktop would time out); output:\n{out}"
)
# The neutral token must come first: current parsers stop at their
# first hit, and they should bind to the newer contract.
assert f"HERMES_BACKEND_READY port={port}" in out
backend_at = out.index(f"HERMES_BACKEND_READY port={port}")
legacy_at = out.index(f"HERMES_DASHBOARD_READY port={port}")
assert backend_at < legacy_at
# Exactly one of each — never a loop of announcements.
assert out.count("HERMES_BACKEND_READY port=") == 1
assert out.count("HERMES_DASHBOARD_READY port=") == 1
finally:
proc.terminate()
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()