Files
hermes-agent/scripts/desktop-update/runtime.ps1
Teknium cb8451baa5 fix: windows hand-off claims the update marker first and never fails a committed update
windows.ps1 implements update-marker contract C1 v2 and the C3 outcome
vocabulary on the Windows Desktop hand-off:

- The marker claim is the script's first action (before any Add-Type, UI
  or probe): exclusive create (tmp + File.Move, never overwrites) with a
  v2 body "<pid>\n<started_at>\nct:<creation time>\n". A live marker owned
  by exactly -DesktopPid is adopted by compare-and-swap keeping its
  started_at; any other live owner refuses the run (exit 2, marker
  untouched, "Nothing was changed"); a dead owner (pid gone, creation time
  mismatch = pid reuse, v1 older than 20 min) is compare-and-deleted and
  the claim retried.
- Release is compare-and-delete on lines 1-3 and keeps the marker while a
  line-4 delegate is alive. When a stalled tree cannot be quiesced (exit 7)
  the marker gains a delegate line naming a surviving job member
  (JobObjectBasicProcessIdList), so it stays live exactly as long as the
  tree does instead of naming the dying script.
- After `hermes update` exits 0 (or the 124 banner remap) the result is
  ok:true, exit_code 0: verify failure (was exit 8), legacy rebuild
  failure (was exit 6), a gateway restart miss, an interrupted post-banner
  phase and any unexpected throw become `warnings` + manual, with a
  "Hermes was updated, but ..." message; gateway start --all still runs
  after a failed verify/rebuild.
- The idle watchdog counts job CPU/IO accounting deltas as progress, so a
  pipe-silent but busy step (download, extraction) is not killed.
- Launcher probes (--print-runtime-command, --version, update --help) run
  with a 60 s timeout and kill their tree on expiry.
- Desktop exit wait checks pid + creation time pinned at start, and a
  still-open Desktop is never relaunched over (exit 4).
- The result file is written atomically, carries started_at (marker line
  2) and warnings, and is no longer deleted at hand-off start.
- Stop-UiServer only removes its own profile dir and dirs whose pid is not
  running (TEMP is shared across HERMES_HOMEs).

(cherry picked from commit af178fd695d7f94bf584bfabe9748cbb752fea13)
2026-10-06 05:44:21 -07:00

90 lines
4.7 KiB
PowerShell

function Invoke-HermesProbe {
param([Parameter(Mandatory = $true)][string]$Exe, [string[]]$Arguments = @())
# Every launcher probe is bounded (60 s): a launcher that hangs (a stuck
# import, an AV scan) fails the probe instead of parking the hand-off
# forever after the Desktop already closed. Timeout kills the probe tree.
$timeout = 60
$parsed = 0
if ($env:HERMES_UPDATE_PROBE_TIMEOUT_SECONDS -and [int]::TryParse($env:HERMES_UPDATE_PROBE_TIMEOUT_SECONDS, [ref]$parsed) -and $parsed -gt 0) {
$timeout = $parsed
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = (@($Arguments) | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' '
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$process = [System.Diagnostics.Process]::Start($psi)
try { $process.StandardInput.Close() } catch {}
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit($timeout * 1000)) {
& taskkill.exe /T /F /PID $process.Id 2>&1 | Out-Null
return @{ Code = 124; Output = ''; TimedOut = $true }
}
# A descendant may hold the pipes open; never wait on it.
$text = if ($stdout.Wait(5000)) { $stdout.Result } else { '' }
[void]$stderr
return @{ Code = $process.ExitCode; Output = $text; TimedOut = $false }
}
function Get-HermesRuntimeCommand {
param(
[Parameter(Mandatory = $true)][string]$InstallRoot,
[string]$Module = 'hermes_cli.main'
)
# The launcher owns interpreter/ABI and generation selection. Never infer
# PM's store layout or borrow a different installation's PATH command.
foreach ($name in @('hermes.exe', 'hermes.cmd')) {
$launcher = Join-Path $InstallRoot ".hermes\bin\$name"
if (Test-Path -LiteralPath $launcher -PathType Leaf) {
$probe = Invoke-HermesProbe $launcher @('--print-runtime-command', '--module', $Module)
if ($probe.TimedOut) { throw "Installation launcher did not answer within the probe timeout: $launcher" }
if ($probe.Code) { throw "Installation launcher failed (exit $($probe.Code)): $launcher" }
$command = @(($probe.Output | ConvertFrom-Json))
if ($command.Count -lt 2 -or @($command | Where-Object { $_ -isnot [string] -or -not $_ }).Count) {
throw "Installation launcher returned invalid command: $launcher"
}
return $command
}
}
# Earlier PM installers published only to user-bin. The established
# --version surface reports the bound source root; never trust PATH alone.
if (Test-Path -LiteralPath (Join-Path $InstallRoot 'hermes_cli/_launchers.py') -PathType Leaf) {
$directories = @(
(Join-Path $env:HERMES_HOME 'bin'),
(Join-Path (Split-Path -Parent $InstallRoot) 'bin')
)
if ($env:LOCALAPPDATA) { $directories += Join-Path $env:LOCALAPPDATA 'hermes/bin' }
foreach ($directory in ($directories | Select-Object -Unique)) {
foreach ($name in @('hermes.exe', 'hermes.cmd')) {
$legacy = Join-Path $directory $name
if (-not (Test-Path -LiteralPath $legacy -PathType Leaf)) { continue }
$probe = Invoke-HermesProbe $legacy @('--version')
$version = $probe.Output
if ($probe.TimedOut -or $probe.Code -or $version -notmatch '(?m)^Install directory: (.+)\r?$') { continue }
$reported = [IO.Path]::GetFullPath($Matches[1].Trim()).TrimEnd('\', '/')
$expected = [IO.Path]::GetFullPath($InstallRoot).TrimEnd('\', '/')
if (-not [string]::Equals($reported, $expected, [StringComparison]::OrdinalIgnoreCase)) { continue }
if ($Module -ne 'hermes_cli.main') {
throw "This older installation needs its launcher refreshed before running $Module. Run the update through $legacy."
}
return @($legacy)
}
}
}
# Only an older, pre-PM checkout may use the historical interpreter.
if (-not (Test-Path -LiteralPath (Join-Path $InstallRoot 'pm') -PathType Container)) {
foreach ($directory in @('venv', '.venv')) {
$python = Join-Path $InstallRoot "$directory\Scripts\python.exe"
if (Test-Path -LiteralPath $python -PathType Leaf) { return @($python, '-m', $Module) }
}
}
throw "Installation launcher is missing under $InstallRoot\.hermes\bin. Repair this installation."
}