From 4280413dba456b7120b428f2f1465ae4e79c060b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 17:03:00 -0500 Subject: [PATCH] fix(update): exempt manual results from the hand-off freshness window A manual:true hand-off result is the durable action-required channel: on a browserless Linux box with no working notifier, the boot dialog is the first and only place the message ever surfaces. The 30-minute freshness gate discarded it if the user reopened Hermes later, stranding exactly the machine the channel exists to serve. Parse before the age check and skip the window for manual results; the file is still unlinked before any age check, so it's surfaced at most once. Ordinary results still expire. Regression: a stale ordinary result is discarded (and consumed) while a stale manual result is still returned once. --- apps/desktop/electron/handoff-result.test.ts | 25 +++++++++++++++++++ apps/desktop/electron/handoff-result.ts | 26 ++++++++++++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index 1bc08f0ea84e1..6d74f7f64965f 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -88,3 +88,28 @@ test('manual flag survives the round trip and defaults false', () => { 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') }) + +test('an old manual result survives the freshness window but an old ordinary one does not', () => { + const stale = Math.floor(Date.now() / 1000) - 3600 + + const ordinary = tempHome() + write(ordinary, { ok: true, exit_code: 0, manual: false, message: 'done', branch: 'main', finished_at: stale }) + assert.equal(readAndConsumeHandoffResult(ordinary), null, 'a stale ordinary result is discarded') + assert.equal(fs.existsSync(handoffResultPath(ordinary)), false, 'and still consumed') + + 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: stale + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result, 'a stale manual result is still surfaced — it is the last-resort channel') + assert.equal(result.manual, true) + assert.equal(readAndConsumeHandoffResult(home), null, 'but only once') +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index 8e473ea08694f..ada072b5178db 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -6,9 +6,17 @@ * path; the relaunched Desktop reads it exactly once on boot and surfaces * failures (a silent failed update looks identical to "nothing happened", * which is how the 2026-08-09 'closed the app then nothing' report was - * born). Read-and-delete so a result is reported at most once; results - * older than the freshness window are discarded unread (a stale file from a - * crashed relaunch chain must not resurface days later). + * born). Read-and-delete so a result is reported at most once; ordinary + * results older than the freshness window are discarded unread (a stale + * file from a crashed relaunch chain must not resurface days later). + * + * manual:true results are exempt from the freshness window. They are the + * durable action-required channel — on a browserless Linux box with no + * working notifier, the boot dialog is the FIRST and ONLY place the message + * ever surfaces, and the user may not reopen Hermes within 30 minutes. + * Dropping it as stale strands exactly the machine it exists to serve. It is + * still consumed once (the file is unlinked before any age check), so it + * cannot resurface on a later boot. */ import fs from 'fs' @@ -61,16 +69,24 @@ export function readAndConsumeHandoffResult( return null } + const manual = Boolean(parsed?.manual) const finishedAt = Number(parsed?.finished_at) - if (!Number.isFinite(finishedAt) || now() - finishedAt * 1000 > maxAgeMs) { + if (!Number.isFinite(finishedAt)) { + return null + } + + // Ordinary results expire; a manual (action-required) result never does — + // it's the last-resort surface for machines with no live channel, so the + // user must see it whenever they next reopen, not only within the window. + if (!manual && now() - finishedAt * 1000 > maxAgeMs) { return null } return { ok: Boolean(parsed?.ok), exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1, - manual: Boolean(parsed?.manual), + manual, message: typeof parsed?.message === 'string' ? parsed.message : '', branch: typeof parsed?.branch === 'string' ? parsed.branch : '' }