fix(update): manual-result protocol so gated outcomes reach the user

Round 4 of helix4u's review — the durable fallback is now real:

- Result protocol gains `manual`: an ok result the user still must act
  on (reopen the app, reinstall the GUI package, fix the sandbox helper).
  Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop
  consumer surfaces manual results in a real dialog on next boot instead
  of a log line — the browserless-Linux disappearance now ends at a
  visible dialog, worst case one boot later. Older result files without
  the field parse as manual:false (covered).
- notify ladder verifies EXECUTION, not existence: zenity/kdialog must
  survive their first second (an instant death means no display and falls
  through); the no-surface case is an explicit best-effort contract whose
  guaranteed channel is the result dialog.
- mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no
  longer swallowed (`|| true` dropped): the durable message carries both
  facts.
- launch/gate matrices assert `manual` in the result JSON; consumer
  round-trip tested in handoff-result.test.ts.
This commit is contained in:
Brooklyn Nicholson
2026-08-11 09:27:01 -05:00
parent ba28a18b95
commit 968ec6c6f4
6 changed files with 80 additions and 21 deletions

View File

@@ -67,3 +67,24 @@ test('malformed JSON is consumed silently', () => {
test('absent file returns null', () => {
assert.equal(readAndConsumeHandoffResult(tempHome()), null)
})
test('manual flag survives the round trip and defaults false', () => {
const home = tempHome()
write(home, {
ok: true,
exit_code: 0,
manual: true,
message: 'Update complete. Reopen Hermes to finish (it could not restart itself).',
branch: 'main',
finished_at: Math.floor(Date.now() / 1000)
})
const result = readAndConsumeHandoffResult(home)
assert.ok(result)
assert.equal(result.ok, true)
assert.equal(result.manual, true)
write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) })
assert.equal(readAndConsumeHandoffResult(home)?.manual, false, 'older writers without the field parse as manual:false')
})

View File

@@ -19,6 +19,11 @@ export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000
export interface HandoffResult {
ok: boolean
exitCode: number
/** Update succeeded but the user must act (reopen the app, reinstall the
* GUI package, fix the sandbox helper). The consumer must SURFACE these —
* an ok:true manual result that only gets logged never reaches the user
* on exactly the machines where no shim/notifier could show it live. */
manual: boolean
message: string
branch: string
}
@@ -65,6 +70,7 @@ export function readAndConsumeHandoffResult(
return {
ok: Boolean(parsed?.ok),
exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1,
manual: Boolean(parsed?.manual),
message: typeof parsed?.message === 'string' ? parsed.message : '',
branch: typeof parsed?.branch === 'string' ? parsed.branch : ''
}

View File

@@ -1815,7 +1815,18 @@ async function waitForUpdateToFinish() {
try {
const result = readAndConsumeHandoffResult(HERMES_HOME)
if (result && result.ok) {
if (result && result.ok && result.manual) {
// Update landed but the user must act (reopen/reinstall/sandbox). On
// machines with no shim browser and no notifier this dialog is the
// FIRST time the message is visible — it must not be a log line.
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
dialog.showMessageBox({
type: 'warning',
title: 'Hermes update',
message: 'The update finished, but needs one more step',
detail: result.message
})
} else if (result && result.ok) {
rememberLog(`[updates] detached update finished OK (branch ${result.branch})`)
} else if (result) {
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)

View File

@@ -80,11 +80,13 @@ json_escape() { # minimal JSON string escape: \ " and control whitespace
}
notify_fallback() { # status message — renderer-free recovery surface.
# Fires only when there is no shim window: a gated/failed outcome must
# never be a silent disappearance (gille rounds 2-3). Each rung FALLS
# THROUGH on execution failure (notify-send existing but unable to reach
# D-Bus must not eat the message), ending at osascript on mac — present
# on every macOS — and at a logged last resort everywhere.
# Fires only when there is no shim window. BEST-EFFORT immediate channel:
# each rung requires EXECUTION acceptance, not existence — notify-send's
# exit code is its acceptance (fire-and-forget), zenity/kdialog must
# survive their first second (a dialog that dies instantly had no display
# and must not eat the message). The GUARANTEED channel is the result
# file: a manual/error outcome is durably marked and the next Desktop
# boot surfaces it in a dialog (handoff-result.ts + main.ts).
case "$1" in manual|error) ;; *) return 0 ;; esac
if [ "$(uname)" = "Darwin" ]; then
/usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0
@@ -92,16 +94,23 @@ notify_fallback() { # status message — renderer-free recovery surface.
if command -v notify-send >/dev/null 2>&1; then
notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0
fi
local p
if command -v zenity >/dev/null 2>&1; then
(zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) && return 0
zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &
p=$!; sleep 1
kill -0 "$p" 2>/dev/null && return 0
wait "$p" 2>/dev/null
fi
if command -v kdialog >/dev/null 2>&1; then
(kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) && return 0
kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &
p=$!; sleep 1
kill -0 "$p" 2>/dev/null && return 0
wait "$p" 2>/dev/null
fi
fi
# Explicit contract for the no-surface case: the result file already
# tells the next boot the truth; log that this was the only channel.
log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2"
# No immediate surface landed. The durable channel takes over: the result
# is marked manual/failed and the next boot shows it in a real dialog.
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
@@ -274,9 +283,12 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is
fi
}
MANUAL=0 # 1 = update landed but the user must act (result protocol field)
write_result() {
printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \
"$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \
"$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \
> "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true
}
@@ -293,7 +305,7 @@ finish() {
# A rejected launch rewrites the result (nothing consumed it — the app
# never started) so the next boot tells the truth too.
deliver_outcome
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && FINAL_MSG="$DONE_NOTE"
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; }
write_result
if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then
@@ -312,15 +324,21 @@ finish() {
# mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled
# back — bring it back up; the note still tells the user to re-run.
# A gated linux outcome (skew/manual) skips the launch BY DESIGN.
launch_app || true
if ! launch_app; then
# Even the kept bundle didn't come back: the durable message must
# carry BOTH facts (update ok, previous app not reopened).
FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually."
write_result
fi
fi
publish "manual" "$DONE_NOTE"; stop_ui leave-window
publish "manual" "$FINAL_MSG"; stop_ui leave-window
elif launch_app; then
publish "done" ""; stop_ui
else
# Launch was due and did not land. Downgrade: truthful result for the
# next boot, manual state held on screen now.
FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)."
MANUAL=1
write_result
publish "manual" "$FINAL_MSG"; stop_ui leave-window
fi

View File

@@ -159,13 +159,13 @@ case "$MODE" in
if [ "$(uname)" != "Darwin" ]; then
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
else
# mac: a SUPPLIED target that is missing is a REJECTED launch and
# must downgrade to manual — never a clean "Update complete."
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true
expect_msg "missing bundle downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
expect_msg "missing bundle downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
fi
# 2. gated skew: success result carries the skew message (the manual
@@ -174,7 +174,7 @@ case "$MODE" in
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true
if [ "$(uname)" != "Darwin" ]; then
expect_msg "skew outcome surfaces in result message" "d['ok']==True and 'was not changed' in d['message']"
expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']"
fi
rm -rf "$L"

View File

@@ -409,13 +409,16 @@ function Close-ProgressWindow {
}
}
function Write-Result([bool]$Ok, [int]$Code, [string]$Message) {
function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) {
# Consumed (read + deleted) by the relaunched Desktop on boot so the
# user actually SEES how a detached update ended.
# user actually SEES how a detached update ended. $ManualAction marks an
# ok result the user still must act on -- the Desktop surfaces those in
# a dialog, not just the log (same protocol as posix.sh).
try {
$obj = @{
ok = $Ok
exit_code = $Code
manual = $ManualAction
message = $Message
branch = $Branch
finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
@@ -733,7 +736,7 @@ try {
# Launch was due and did not verifiably land: truthful result
# for the next boot, manual state held on screen now.
$finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)."
Write-Result $true 0 $finalMsg
Write-Result $true 0 $finalMsg $true
Show-ManualFinale $finalMsg
}
Close-ProgressWindow