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.
This commit is contained in:
Brooklyn Nicholson 2026-08-11 17:03:00 -05:00
parent 968ec6c6f4
commit 4280413dba
2 changed files with 46 additions and 5 deletions

View File

@ -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')
})

View File

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