diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts new file mode 100644 index 0000000000000..ebcd630a815b0 --- /dev/null +++ b/apps/desktop/electron/handoff-result.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { handoffResultPath, readAndConsumeHandoffResult } from './handoff-result' + +function tempHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-result-')) +} + +function write(home: string, body: any) { + fs.writeFileSync(handoffResultPath(home), typeof body === 'string' ? body : JSON.stringify(body)) +} + +test('consumes and returns a fresh failure result', () => { + const home = tempHome() + write(home, { ok: false, exit_code: 6, message: 'rebuild failed', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result) + assert.equal(result.ok, false) + assert.equal(result.exitCode, 6) + assert.equal(result.message, 'rebuild failed') + assert.equal(fs.existsSync(handoffResultPath(home)), false, 'result file must be consumed') +}) + +test('reports each result at most once', () => { + const home = tempHome() + write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + + assert.ok(readAndConsumeHandoffResult(home)) + assert.equal(readAndConsumeHandoffResult(home), null) +}) + +test('discards stale results but still consumes the file', () => { + const home = tempHome() + write(home, { ok: false, exit_code: 5, message: 'old', branch: 'main', finished_at: Math.floor(Date.now() / 1000) - 3600 }) + + assert.equal(readAndConsumeHandoffResult(home), null) + assert.equal(fs.existsSync(handoffResultPath(home)), false) +}) + +test('malformed JSON is consumed silently', () => { + const home = tempHome() + write(home, '{nope') + + assert.equal(readAndConsumeHandoffResult(home), null) + assert.equal(fs.existsSync(handoffResultPath(home)), false) +}) + +test('absent file returns null', () => { + assert.equal(readAndConsumeHandoffResult(tempHome()), null) +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts new file mode 100644 index 0000000000000..c724be66e3118 --- /dev/null +++ b/apps/desktop/electron/handoff-result.ts @@ -0,0 +1,71 @@ +/** + * Consume the detached update hand-off's result file (#82328 follow-up). + * + * scripts/desktop-update.ps1 runs hidden/detached — the user never sees its + * console. It writes HERMES_HOME/.hermes-update-result.json on every exit + * 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). + */ + +import fs from 'fs' +import path from 'path' + +export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000 + +export interface HandoffResult { + ok: boolean + exitCode: number + message: string + branch: string +} + +export function handoffResultPath(hermesHome: string): string { + return path.join(hermesHome, '.hermes-update-result.json') +} + +export function readAndConsumeHandoffResult( + hermesHome: string, + { now = Date.now, maxAgeMs = HANDOFF_RESULT_MAX_AGE_MS }: { now?: () => number; maxAgeMs?: number } = {} +): HandoffResult | null { + const file = handoffResultPath(hermesHome) + let raw: string + + try { + raw = fs.readFileSync(file, 'utf8') + } catch { + return null + } + + // Consume unconditionally — even a malformed/stale file must not be + // re-reported on every subsequent boot. + try { + fs.unlinkSync(file) + } catch { + // Best-effort; a locked file just gets consumed on the next boot. + } + + let parsed: any + + try { + parsed = JSON.parse(raw) + } catch { + return null + } + + const finishedAt = Number(parsed?.finished_at) + + if (!Number.isFinite(finishedAt) || now() - finishedAt * 1000 > maxAgeMs) { + return null + } + + return { + ok: Boolean(parsed?.ok), + exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1, + message: typeof parsed?.message === 'string' ? parsed.message : '', + branch: typeof parsed?.branch === 'string' ? parsed.branch : '' + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 84bfeedd632cd..1139c55498ce4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -121,6 +121,7 @@ import { removeWorktree, switchBranch } from './git-worktree-ops' +import { readAndConsumeHandoffResult } from './handoff-result' import { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, clampDataUrlReadMaxMb, @@ -206,7 +207,8 @@ import { resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, spawnUpdaterProcess, - stagedUpdaterSupportsPrewrittenMarker + stagedUpdaterSupportsPrewrittenMarker, + wrapHandoffForDetachedConsole } from './updater-process' import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' @@ -1802,6 +1804,28 @@ async function waitForUpdateToFinish() { timeoutMs: UPDATE_WAIT_TIMEOUT_MS }) + // The detached hand-off script (scripts/desktop-update.ps1) runs hidden; + // its result file is the ONLY way the user learns a detached update + // failed. Consume it exactly once, here, right where boot passes the + // update gate — success gets a log line, failure gets a real dialog + // (previously a failed detached update was indistinguishable from + // "nothing happened"). + try { + const result = readAndConsumeHandoffResult(HERMES_HOME) + + 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}`) + dialog.showErrorBox( + 'Hermes update did not finish', + `${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}` + ) + } + } catch (err) { + rememberLog(`[updates] could not read hand-off result: ${err.message}`) + } + if (outcome === 'clear') { return false } @@ -2997,8 +3021,14 @@ async function applyUpdates(opts = {}) { let child if (scriptHandoff) { - const scriptArgs = [ - ...scriptHandoff.args, + // A bare detached+hidden powershell spawn silently dies before -File + // processing (console-subsystem init failure — see + // wrapHandoffForDetachedConsole). Route through `cmd start` so the + // script gets its own minimized console and survives our exit. The + // wrapper cmd.exe exits immediately, so child.pid is NOT the script's + // pid — the script claims the update marker itself with its own $PID + // as its first action, and a relaunched Desktop parks on that. + const wrapped = wrapHandoffForDetachedConsole(scriptHandoff, [ '-InstallRoot', updateRoot, '-Branch', @@ -3007,9 +3037,9 @@ async function applyUpdates(opts = {}) { String(process.pid), '-RelaunchExe', process.execPath - ] + ]) - child = spawnUpdaterProcess(scriptHandoff.command, scriptArgs, { + child = spawnUpdaterProcess(wrapped.command, wrapped.args, { cwd: HERMES_HOME, env: { ...process.env, @@ -3020,11 +3050,13 @@ async function applyUpdates(opts = {}) { stdio: 'ignore' }) - // The script's own pid owns the marker. Unlike the stale-binary path - // there is NO adoption hazard: hermes_cli/update_lock.py accepts a live - // marker held by a process ANCESTOR (the script is the `hermes update` - // child's parent), so the pre-write is always safe here — no - // stagedUpdaterSupportsPrewrittenMarker() mtime heuristics needed. + // Bridge marker: child.pid is the short-lived cmd.exe WRAPPER, not the + // script (see wrapHandoffForDetachedConsole). Write it anyway to cover + // the first moments of the hand-off — the script's step 0 overwrites it + // with its own live $PID, and if the script never starts the wrapper's + // dead pid makes the marker read as stale and self-delete (no wedge). + // The `hermes update` child adopts the SCRIPT's claim via + // update_lock.py's process-ancestry rule; no mtime heuristics needed. if (Number.isInteger(child.pid)) { writeUpdateMarker(HERMES_HOME, child.pid) } diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index d6bda4e91a9ea..06e6ffc6cf8ec 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -9,7 +9,8 @@ import { resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, spawnUpdaterProcess, - stagedUpdaterSupportsPrewrittenMarker + stagedUpdaterSupportsPrewrittenMarker, + wrapHandoffForDetachedConsole } from './updater-process' const DAY_MS = 24 * 60 * 60 * 1000 @@ -199,3 +200,22 @@ test('resolveUpdateScriptHandoff is Windows-only (POSIX updates in place)', () = assert.equal(handoff, null) }) + +test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => { + const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` + const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const handoff = resolveUpdateScriptHandoff(root, { + isWindows: true, + fileExists: candidate => candidate === expected + }) + + assert.ok(handoff) + const wrapped = wrapHandoffForDetachedConsole(handoff, ['-InstallRoot', root, '-Branch', 'main']) + + assert.equal(wrapped.command, 'cmd.exe') + assert.deepEqual(wrapped.args, [ + '/d', '/s', '/c', 'start', '', '/min', + 'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected, + '-InstallRoot', root, '-Branch', 'main' + ]) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index 66faf9316b48f..66a279a200646 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -61,6 +61,36 @@ export function resolveUpdateScriptHandoff( } } +/** + * Wrap a PowerShell hand-off invocation so it survives a detached, hidden + * spawn from Electron. + * + * Verified empirically (2026-08-09, Windows 11): `spawn('powershell', [..., + * '-File', script], { detached: true, stdio: 'ignore', windowsHide: true })` + * exits 0 WITHOUT executing a single line of the script. powershell.exe is a + * console-subsystem binary; detached+windowsHide gives it no console to + * attach to, and Windows PowerShell 5.1 dies during console init before + * -File processing (the same class of failure as #54220's conhost work, on + * the launch side). The same spawn with a visible console, or non-detached, + * runs fine — so unit tests and foreground use hide the bug. + * + * `cmd /c start "" /min powershell ...` was the variant that survived the + * full detached+hidden production shape in testing: `start` allocates the + * child its own (minimized) console and fully detaches it from cmd.exe, + * which exits immediately. The spawned pid is therefore the WRAPPER's — + * callers must not use it as a marker owner (the script claims the marker + * itself with its own $PID). + */ +export function wrapHandoffForDetachedConsole(handoff: UpdateScriptHandoff, extraArgs: string[]): { + command: string + args: string[] +} { + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', 'start', '', '/min', handoff.command, ...handoff.args, ...extraArgs] + } +} + export interface ResolveStagedUpdaterBinaryDeps { isWindows?: boolean fileExists?: (candidate: string) => boolean diff --git a/scripts/desktop-update.ps1 b/scripts/desktop-update.ps1 index fe90707975ec4..349b4b780ba98 100644 --- a/scripts/desktop-update.ps1 +++ b/scripts/desktop-update.ps1 @@ -9,141 +9,330 @@ # published. In practice binaries go months stale and users hit long-fixed # bugs on every update (the 2026-08-09 incident chain). # -# This script inverts that: it lives in the repo checkout, so EVERY -# `hermes update` refreshes the very code that drives the next update. The -# Desktop spawns it detached (see resolveUpdateScriptHandoff in -# apps/desktop/electron/main.ts) and exits; only PowerShell itself -- an OS -# component -- is "frozen". +# This script lives in the repo checkout, so EVERY `hermes update` refreshes +# the very code that drives the next update. The Desktop spawns it through a +# `cmd start` wrapper (see wrapHandoffForDetachedConsole in +# apps/desktop/electron/updater-process.ts -- a bare detached+hidden +# powershell dies before -File runs) and exits; only PowerShell itself -- an +# OS component -- is "frozen". # # CONTRACT (keep in sync with apps/desktop/electron/main.ts): -# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\desktop-update.ps1 +# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass +# -File scripts\desktop-update.ps1 # -InstallRoot repo checkout (HERMES_HOME\hermes-agent) # -Branch branch to update against # -DesktopPid the Electron main process to wait out # [-RelaunchExe ] Hermes.exe to start when done (omit = no relaunch) +# [-NoUi] headless (tests); default shows a progress window # [-NoMarkerCleanup] leave .hermes-update-in-progress in place (tests) # -# The Desktop pre-writes HERMES_HOME\.hermes-update-in-progress with THIS -# process's pid before quitting. Contracts that already exist make that safe: -# * hermes_cli/update_lock.py `acquire` treats a live marker owned by a -# process ANCESTOR as its own orchestrator -- our `hermes update` child -# adopts the claim instead of refusing (no HANDOFF_PID_ENV needed). -# * electron/update-marker.ts gates backend startup on the marker, so a -# relaunched Desktop parks instead of spawning a venv-locking backend -# into the update window. -# We delete the marker on every exit path; a crash self-heals via the -# 20-minute staleness ceiling both readers enforce. +# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never +# exits, or a venv shim that never unlocks, aborts the hand-off without +# mutating the install -- a skipped update is recoverable, a half-updated +# venv is not. Every exit path (success, abort, crash) writes +# .hermes-update-result.json for the relaunched Desktop to surface, and +# relaunches the Desktop so the user is never left stranded. +# +# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as +# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits +# immediately). hermes_cli/update_lock.py's ancestry rule lets our +# `hermes update` child adopt the claim; electron/update-marker.ts parks a +# relaunched Desktop on it. Cleanup only removes the marker while WE still +# own it (a handoff partner that rewrote it keeps its claim). param( [Parameter(Mandatory = $true)][string]$InstallRoot, [string]$Branch = "main", [int]$DesktopPid = 0, [string]$RelaunchExe = "", + [switch]$NoUi, [switch]$NoMarkerCleanup ) $ErrorActionPreference = "Continue" $HermesHome = Split-Path -Parent $InstallRoot $MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress" -$LogPath = Join-Path $HermesHome "logs\desktop-update-handoff.log" +$LogDir = Join-Path $HermesHome "logs" +$LogPath = Join-Path $LogDir "desktop-update-handoff.log" +$ResultPath = Join-Path $HermesHome ".hermes-update-result.json" +$script:Ui = $null function Write-HandoffLog([string]$Message) { $line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {} Write-Host $line + if ($script:Ui) { + try { + $script:Ui.Box.AppendText($Message + "`r`n") + [System.Windows.Forms.Application]::DoEvents() + } catch {} + } +} + +function Show-ProgressWindow { + if ($NoUi) { return } + try { + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $form = New-Object System.Windows.Forms.Form + $form.Text = "Hermes Update" + $form.Size = New-Object System.Drawing.Size(720, 420) + $form.StartPosition = "CenterScreen" + $form.ControlBox = $false + $form.TopMost = $true + $label = New-Object System.Windows.Forms.Label + $label.Text = "Updating Hermes -- do not close this window. Hermes restarts automatically when the update finishes." + $label.Dock = "Top" + $label.Height = 34 + $label.Padding = New-Object System.Windows.Forms.Padding(8, 8, 8, 0) + $bar = New-Object System.Windows.Forms.ProgressBar + $bar.Style = "Marquee" + $bar.MarqueeAnimationSpeed = 30 + $bar.Dock = "Top" + $bar.Height = 18 + $box = New-Object System.Windows.Forms.TextBox + $box.Multiline = $true + $box.ReadOnly = $true + $box.ScrollBars = "Vertical" + $box.Dock = "Fill" + $box.Font = New-Object System.Drawing.Font("Consolas", 9) + $form.Controls.Add($box) + $form.Controls.Add($bar) + $form.Controls.Add($label) + $form.Show() + [System.Windows.Forms.Application]::DoEvents() + $script:Ui = [pscustomobject]@{ Form = $form; Box = $box } + } catch { + # Headless session / WinForms unavailable: degrade to log-only. + $script:Ui = $null + } +} + +function Close-ProgressWindow { + if ($script:Ui) { + try { $script:Ui.Form.Close() } catch {} + $script:Ui = $null + } +} + +function Write-Result([bool]$Ok, [int]$Code, [string]$Message) { + # Consumed (read + deleted) by the relaunched Desktop on boot so the + # user actually SEES how a detached update ended. + try { + $obj = @{ + ok = $Ok + exit_code = $Code + message = $Message + branch = $Branch + finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($ResultPath, $obj) + } catch {} } -function Remove-Marker { +function Remove-MarkerIfOwned { if ($NoMarkerCleanup) { return } try { if (Test-Path -LiteralPath $MarkerPath) { - Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue - Write-HandoffLog "removed update marker" + $firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue) + if ("$firstLine".Trim() -eq "$PID") { + Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue + Write-HandoffLog "removed update marker (owned)" + } else { + Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)" + } } } catch {} } +function Start-DesktopRelaunch { + if ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe)) { + Write-HandoffLog "relaunching desktop: $RelaunchExe" + try { + Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) | Out-Null + } catch { + Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)" + } + } +} + +function Invoke-StreamedHermes([string]$Exe, [string[]]$HermesArgs, [string]$Tag) { + # Start-Process + output file + poll keeps the WinForms window pumping + # during long silent stretches (pip installs); a blocking pipeline would + # freeze the marquee. Returns @{ Code; Output }. + $outFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.out" -f $Tag, $PID) + $errFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.err" -f $Tag, $PID) + Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue + # System.Diagnostics.Process directly: Start-Process's .ExitCode is + # unreliably $null under PS 5.1 even with the Handle-touch workaround. + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $Exe + # .Arguments string (PS 5.1 / .NET Framework has no ArgumentList). + # Args here are fixed flags + a branch ref; quote each defensively. + $psi.Arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' ' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $proc = [System.Diagnostics.Process]::Start($psi) + $outWriter = [System.IO.File]::CreateText($outFile) + $errWriter = [System.IO.File]::CreateText($errFile) + # Pump synchronously in small reads so the UI stays alive; stderr is + # drained at the end (hermes update is stdout-dominant). + while (-not $proc.HasExited) { + while (-not $proc.StandardOutput.EndOfStream) { + $ln = $proc.StandardOutput.ReadLine() + if ($null -ne $ln) { + $outWriter.WriteLine($ln) + if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) } + } + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } + } + Start-Sleep -Milliseconds 150 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } + } + while (-not $proc.StandardOutput.EndOfStream) { + $ln = $proc.StandardOutput.ReadLine() + if ($null -ne $ln) { + $outWriter.WriteLine($ln) + if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) } + } + } + $errText = $proc.StandardError.ReadToEnd() + if ($errText) { + $errWriter.Write($errText) + foreach ($ln in ($errText -split "`r?`n")) { + if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) } + } + } + $outWriter.Close(); $errWriter.Close() + $proc.WaitForExit() + $code = $proc.ExitCode + $all = "" + try { $all = [System.IO.File]::ReadAllText($outFile) } catch {} + if ($errText) { $all += "`n" + $errText } + Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue + return @{ Code = $code; Output = $all } +} + +$finalCode = 1 +$finalMsg = "update did not complete" try { + New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null + Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + Show-ProgressWindow Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID" - # -- 1. Wait for the Desktop to actually exit ------------------------- - # The Desktop quits right after spawning us, but Electron teardown is - # asynchronous. Bounded wait; a Desktop that never exits is a bug we - # surface rather than fight. + # -- 0. Claim the update marker with OUR pid --------------------------- + try { + $epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and + # the marker contract (Rust/TS/Python readers) is "\n\n". + [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n") + Write-HandoffLog "claimed update marker (pid $PID)" + } catch { + Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)" + } + + # -- 1. Wait for the Desktop to exit (FAIL CLOSED) ---------------------- if ($DesktopPid -gt 0) { $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline) { $proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue if (-not $proc) { break } Start-Sleep -Milliseconds 300 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } } - $still = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue - if ($still) { - Write-HandoffLog "WARNING: desktop pid $DesktopPid still alive after 30s; proceeding (hermes update has its own guards)" - } else { - Write-HandoffLog "desktop exited" + if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) { + # A live Desktop means a live backend re-locking the venv at any + # moment. Updating under it is how installs brick. Abort. + $finalCode = 4 + $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + Write-HandoffLog $finalMsg + exit $finalCode } + Write-HandoffLog "desktop exited" } - # -- 2. Wait for the venv shim to unlock ------------------------------ - # Mirrors the Rust updater's is_locked(): a running exe refuses O_RDWR. + # -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------ $shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe" if (Test-Path -LiteralPath $shim) { + $unlocked = $false $deadline = (Get-Date).AddSeconds(20) while ((Get-Date) -lt $deadline) { try { $fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None') $fs.Close() - Write-HandoffLog "venv shim unlocked" + $unlocked = $true break } catch { Start-Sleep -Milliseconds 400 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } } } + if (-not $unlocked) { + # Something still maps the venv. --force-ing past it guarantees a + # half-updated venv (the exact 2026-08-09 Access-denied brick). + $finalCode = 5 + $finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again." + Write-HandoffLog $finalMsg + exit $finalCode + } + Write-HandoffLog "venv shim unlocked" } - # -- 3. Run the update from the CURRENT checkout ---------------------- - # hermes update handles everything downstream: gateway pause, venv-holder - # guard (with orphan reap), dep sync, desktop rebuild, skills/config sync, - # gateway restart. --force skips only the hermes.exe shim guard, which by - # this point is provably unlocked (step 2); the venv-python holder guard - # stays active. Our marker claim is adopted by the child via the - # process-ancestry rule in hermes_cli/update_lock.py. + # -- 3. Run the update from the CURRENT checkout ------------------------ + # --force skips only the hermes.exe shim guard, which step 2 just PROVED + # is unlocked; the venv-python holder guard (orphan reap included) stays + # active. Our marker claim is adopted by the child via update_lock.py's + # process-ancestry rule. $hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe" if (-not (Test-Path -LiteralPath $hermesExe)) { - Write-HandoffLog "ERROR: $hermesExe missing - install too broken for the script hand-off; falling back is the Desktop's job" - exit 3 + $finalCode = 3 + $finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." + Write-HandoffLog $finalMsg + exit $finalCode } - Write-HandoffLog "running: hermes update --yes --gateway --force --branch $Branch" - & $hermesExe update --yes --gateway --force --branch $Branch 2>&1 | ForEach-Object { - Write-HandoffLog ("update| " + $_) - } - $updateExit = $LASTEXITCODE - Write-HandoffLog "hermes update exit code: $updateExit" + $updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch) + Write-HandoffLog ("running: hermes " + ($updateArgs -join " ")) + $res = Invoke-StreamedHermes $hermesExe $updateArgs "update" + Write-HandoffLog "hermes update exit code: $($res.Code)" - if ($updateExit -ne 0) { + if ($res.Code -ne 0 -and $res.Code -ne 2) { # One retry for the update-boundary class (fresh code on disk, stale - # code in memory -- same rationale as the Tauri updater's retry). - # Skip for exit 2: "close all Hermes windows" is not retryable. - if ($updateExit -ne 2) { - Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" - & $hermesExe update --yes --gateway --force --branch $Branch 2>&1 | ForEach-Object { - Write-HandoffLog ("update| " + $_) - } - $updateExit = $LASTEXITCODE - Write-HandoffLog "retry exit code: $updateExit" - } + # code in memory). Exit 2 ("close all Hermes windows") is not retryable. + Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" + $res = Invoke-StreamedHermes $hermesExe $updateArgs "update" + Write-HandoffLog "retry exit code: $($res.Code)" } - # -- 4. Relaunch the Desktop ------------------------------------------ - # Marker must be gone BEFORE relaunch or the new Desktop parks on it. - Remove-Marker - if ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe)) { - Write-HandoffLog "relaunching desktop: $RelaunchExe" - Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) | Out-Null + # -- 4. Truthful completion: don't trust exit 0 ------------------------- + # `hermes update` treats a Desktop GUI build failure as NON-fatal (prints + # a one-line warning, exits 0). For a Desktop-DRIVEN update that warning + # is fatal: we would relaunch the old exe and call it success. Detect it, + # retry the build once, and propagate honestly. + $desktopBuildFailed = $false + if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") { + Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build" + $rebuild = Invoke-StreamedHermes $hermesExe @("desktop", "--force-build", "--build-only") "rebuild" + Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)" + if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true } } - exit $updateExit + if ($res.Code -eq 0 -and -not $desktopBuildFailed) { + $finalCode = 0 + $finalMsg = "Update complete." + } elseif ($desktopBuildFailed) { + $finalCode = 6 + $finalMsg = "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." + } else { + $finalCode = $res.Code + $finalMsg = "hermes update failed (exit $($res.Code)). See logs\desktop-update-handoff.log." + } + exit $finalCode } finally { - Remove-Marker + Write-Result ($finalCode -eq 0) $finalCode $finalMsg + Remove-MarkerIfOwned + Close-ProgressWindow + Start-DesktopRelaunch }