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 : '' }