fix(update): one elapsed clock, served to the shim by both orchestrators

The shim is a shared page, but only windows.ps1 publishes a stage and an
elapsed count. On mac and Linux posix.sh publishes `running` with an empty
message and no clock, so the running branch rendered the h2 back into the
muted line ("Updating Hermes" twice) and started a clock in the browser,
losing "Hermes will open once done." on both platforms.

A clock started in the page measures when the window painted, not how long
the update has been running -- on posix that is the only clock there is, and
it reads zero after the desktop-exit wait has already burned 30s. That is the
hardcoded-milestone problem #75895 removed, in a new costume.

So: elapsed comes from the orchestrator or is not shown. serve-ui.py stamps
it per request from the hand-off start (a value written into the status file
would freeze between publishes, which are minutes apart -- exactly the stall
the line exists to disprove), matching what Windows' in-process listener
already does. posix.sh gets the stages it was missing, at the four gates it
genuinely waits on. Absent a stage the page keeps the settled copy, and an
old orchestrator that sends no clock simply shows no clock.
This commit is contained in:
Brooklyn Nicholson
2026-08-19 13:13:36 -05:00
parent 107531549a
commit cf2ab8522a
4 changed files with 57 additions and 24 deletions

View File

@@ -64,6 +64,7 @@ LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true
LOG="$LOG_DIR/desktop-update-handoff.log"
RESULT="$HERMES_HOME/.hermes-update-result.json"
STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$"
STARTED_AT="$(date +%s)" # the shim's elapsed clock; see serve-ui.py
UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1
FINAL_MSG="update did not complete"
@@ -147,9 +148,19 @@ notify_fallback() { # status message — renderer-free recovery surface.
log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2"
}
publish() { # status message -- atomic replace; the server reads per poll
write_status() { # status message -- atomic replace; the server reads per poll
printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \
&& mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true
}
publish_stage() { # a long wait the orchestrator is already gating on. No poll
# beat (that would add a second per stage to every update) and no
# notification fallback (there is nothing here for the user to act on).
write_status "running" "$1"
}
publish() { # terminal event -- the page must render it before teardown
write_status "$1" "$2"
[ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state
[ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2"
}
@@ -178,7 +189,7 @@ start_ui() {
browser="$(find_browser)"
{ [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; }
publish "running" ""
publish_stage ""
# The Desktop's final teardown targets the updater process group. Put both
# UI processes in their own sessions so neither the HTTP server nor a Chrome
# renderer becomes collateral damage (Chrome surfaces that renderer death as
@@ -190,7 +201,7 @@ start_ui() {
# window showed ERR_CONNECTION_REFUSED for the whole run; upstream #66753).
# stop_ui ends the server with SIGKILL instead — it is stateless HTTP.
"$py" -c 'import os, signal, sys; os.setsid(); signal.signal(signal.SIGTERM, signal.SIG_IGN); signal.signal(signal.SIGHUP, signal.SIG_IGN); os.execv(sys.argv[1], sys.argv[1:])' \
"$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" &
"$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" "$STARTED_AT" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" &
UI_SERVER_PID=$!
for i in $(seq 1 10); do
port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)"
@@ -264,6 +275,7 @@ mac_swap() {
# the copy in. Every step checked; a failed final move ROLLS BACK so the
# user always has a launchable app, and the result file tells the truth.
if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then
publish_stage "Installing the new app"
rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true
if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
@@ -486,6 +498,7 @@ cd "$INSTALL_ROOT" || {
}
export PYTHONUNBUFFERED=1
log "running: hermes update --yes --gateway --branch $BRANCH"
publish_stage "Updating code and dependencies"
OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$?
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
log "hermes update exit code: $CODE"
@@ -494,6 +507,7 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
# Retry once: update-boundary class (fresh code on disk, stale in memory).
# Exit 2 ("close all Hermes windows") is not retryable.
log "retrying once (freshly pulled fix loads on the second run)"
publish_stage "Retrying update"
OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$?
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
log "retry exit code: $CODE"
@@ -505,6 +519,7 @@ trap 'on_signal TERM' TERM
# and call it success -- retry the build once, propagate honestly.
if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then
log "desktop build failed inside hermes update; retrying build"
publish_stage "Rebuilding Desktop"
"$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || {
FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry."
exit 6

View File

@@ -4,30 +4,45 @@ Two GET routes: / serves ui.html, /progress serves the status file the
orchestrator script writes ({"status": "running"|"done"|"error", ...}).
Exists because a file:// page cannot receive events from a detached
process. Prints the chosen ephemeral port on stdout, serves until killed.
`elapsed_seconds` is stamped per request, not read from the status file:
stages are minutes apart, so a value frozen at the last publish would sit
still through the longest waits -- exactly the stall the page exists to
disprove. Windows' in-process listener computes it the same way.
"""
import http.server
import json
import socketserver
import sys
import time
html_path, status_path = sys.argv[1], sys.argv[2]
started_at = float(sys.argv[3]) if len(sys.argv) > 3 else time.time()
with open(html_path, "rb") as f:
HTML = f.read()
def progress_body():
try:
with open(status_path, "rb") as f:
state = json.loads(f.read())
if not isinstance(state, dict):
raise ValueError(state)
except Exception:
state = {"status": "running", "message": ""}
state["elapsed_seconds"] = max(0, int(time.time() - started_at))
return json.dumps(state).encode("utf-8")
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A002 - base class signature
pass
def do_GET(self):
if self.path.startswith("/progress"):
try:
with open(status_path, "rb") as f:
body = f.read()
json.loads(body)
except Exception:
body = b'{"status":"running","message":""}'
body = progress_body()
ctype = "application/json; charset=utf-8"
elif self.path == "/":
body, ctype = HTML, "text/html; charset=utf-8"

View File

@@ -4,8 +4,9 @@
Served over loopback by the orchestrator (windows.ps1 / posix.sh) into a
chromeless browser app window. Pure veneer: polls /progress for the current
hand-off stage or a terminal event and reacts; owns nothing (relaunch, result file, marker hygiene
all live in the orchestrator, which runs identically with no UI at all).
hand-off stage or a terminal event and reacts; owns nothing (relaunch,
result file, marker hygiene all live in the orchestrator, which runs
identically with no UI at all).
The visual is PR #75895's update hand-off screen, ported verbatim:
- Loader: the desktop's "Fourier Flow" curve. Math + tuning lifted from
@@ -13,7 +14,9 @@
apps/desktop/src/components/ui/loader.tsx 'fourier-flow'). Keep the
constants in sync if the desktop's curve is retuned.
- Layout: loader (size-20) + one title + one muted stage/elapsed line. No
progress bar, stage list, log pane, or cancel (see #75895 for the arguments).
progress bar, stage list, log pane, or cancel (see #75895 for the
arguments). Elapsed comes from the orchestrator or is omitted -- a clock
started here would measure when this window painted, not the update.
- Appearance follows the OS. Dark seeds are the installer's neutral
charcoal (#232323 base, foreground #d6d6d6) — never brand blue.
-->
@@ -89,6 +92,7 @@
font-size: 12px;
line-height: 1.5;
color: var(--muted-foreground);
white-space: pre-line;
}
p code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
@@ -104,7 +108,7 @@
<div id="loader" role="status" aria-label="Updating"></div>
<div id="glyph"></div>
<h2 id="title">Updating Hermes</h2>
<p id="line">Preparing update<br>0s elapsed</p>
<p id="line">Hermes will open once done.</p>
</div>
<script>
/* ── Fourier Flow loader, ported verbatim from loader.tsx ─────────────── */
@@ -199,9 +203,12 @@
const titleEl = document.getElementById('title')
const lineEl = document.getElementById('line')
const glyphEl = document.getElementById('glyph')
const clientStartedAt = Date.now()
const defaultLine = lineEl.textContent /* what a stage-less run says */
let settled = false
const elapsedText = s =>
s < 60 ? `${s}s elapsed` : `${Math.floor(s / 60)}m ${s % 60}s elapsed`
function settle(state) {
settled = true
window.cancelAnimationFrame(frame)
@@ -211,15 +218,11 @@
function apply(state) {
if (settled) return
if (state.status === 'running') {
const reported = Number(state.elapsed_seconds)
const elapsed = Number.isFinite(reported) && reported >= 0
? Math.floor(reported)
: Math.floor((Date.now() - clientStartedAt) / 1000)
const elapsedText = elapsed < 60
? `${elapsed}s elapsed`
: `${Math.floor(elapsed / 60)}m ${elapsed % 60}s elapsed`
lineEl.textContent = `${state.message || 'Updating Hermes'}\n${elapsedText}`
lineEl.style.whiteSpace = 'pre-line'
const stage = state.message || defaultLine
const elapsed = Number(state.elapsed_seconds)
lineEl.textContent = Number.isFinite(elapsed) && elapsed >= 0
? `${stage}\n${elapsedText(Math.floor(elapsed))}`
: stage
} else if (state.status === 'done') {
settle('done')
glyphEl.textContent = '\u2713'

View File

@@ -83,7 +83,7 @@ $LogDir = Join-Path $HermesHome "logs"
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
$ResultPath = Join-Path $HermesHome ".hermes-update-result.json"
$script:Ui = $null
$script:UiStage = "Preparing update"
$script:UiStage = "Hermes will open once done." # until the first gate; matches ui.html
$script:UiStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
function Write-HandoffLog([string]$Message) {