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)
posix.sh now implements the update-marker contract (C1/C2) as a lock:
- the daemon claims `.hermes-update-in-progress` as its first action with a v2
body (pid, started_at, `ct:` creation time) via an atomic no-replace create
(link(2)); a LIVE foreign owner refuses with exit 2 regardless of age, a
marker owned by --desktop-pid is adopted keeping its started_at, a dead one
is compare-and-deleted; liveness honours a line-4 `delegate:` (the running
`hermes update`), so an orphaned update is never reclaimed;
- the launcher waits for the daemon's claim and exits non-zero when it never
lands, so the Desktop does not quit into nothing;
- release is compare-and-delete and keeps a marker whose delegate is alive.
Outcomes follow C3: a legacy rebuild failure, a mac bundle stage/swap failure
or a relaunch gate after `hermes update` exited 0 is ok:true + warnings (no
more exit 6 / exit 7 "still on the previous version"). The result carries
started_at and warnings and is written via a per-pid tmp + rename.
Crash safety: start-of-run recovery restores an interrupted .app swap from its
aside copy (both swappers' suffixes) and a killed TCC heal backup, and sweeps
UI/probe litter of dead pids; the two swap renames are a signal-deferred
critical section. `hermes desktop`'s Python swappers recover the same state
instead of deleting the only copy first.
Also: probes are bounded (60 s), `hermes update` streams into the log, the
update child gets default TERM and the daemon default HUP (no SIG_IGN leak into
gateways or the relaunched app), the relaunched Linux app drops the update env,
the Desktop wait is zombie/pid-reuse aware and never relaunches over a live
app, serve-ui.py exits with its orchestrator, and the job section is parsed as
one brace group so `hermes update` rewriting posix.sh cannot shift the rest.
(cherry picked from commit c7dd9fab00b8af09321ec982d07a62f03351355d)
On main the remote-lifecycle test file already sits at its code-health
line cap (2246; over the 2000 target, so it may only shrink). This
branch's fakeSsh rework added one net line and the check refused it.
The comment explaining mutexWrapped moves onto the declaration it
explains; no code or assertion changes.
checkRendererSkew deferred the warning whenever the marker file existed;
Desktop no longer deletes dead markers (A7 rule 3), so a crashed update's
leftover suppressed it indefinitely. The skew checker now accepts an async
isUpdating and main defers only on a LIVE marker (readLiveUpdateMarker,
owner liveness), the in-flight update, or the hand-off. A sync predicate
keeps the check synchronous (review K132345 P3 renderer-skew-dead-marker).
a94916f0313 dropped the failed-receipt fast exit (#122206) because a retry
still reads `failed` while it runs. At this head `hermes update` still writes
v1 markers (pid + started, no `ct:`), which read live on a bare alive pid for
up to V1_MAX_AGE_S, so a recycled pid on Windows parked boot ~20 min again
if this landed before the lock PR.
The gate now treats a live v1 marker (no `ct:`, no delegate) as over when
latest.json records `failed` with a finished_at after the marker's start
second: that update already ended. A retry's marker starts after the old
receipt and a v2 owner's creation time tells a reused pid apart, so both
still wait. The marker is left in place. Also drops the update-gate test
that set the removed `hasFailedReceipt` dep, which nothing read (review
K132345-v1-before-lock, K132345 P3 dead-failed-receipt-test).
main deleted a dead marker and proceeded. The gate now asks the script
helper instead, and every `error` (non-zero exit, timeout, unreadable
script, bad output) kept it shut with no ceiling: machines where bash or
PowerShell is blocked hit the blocked screen after every crashed update,
in the primary and every pool/profile wait.
A body the helper has never answered for now gets HELPER_ERROR_ATTEMPTS
(3) consecutive tries at the 5 s re-probe without a blocked screen, then
the gate proceeds as main did (the marker is left in place), process-wide
so a later pool wait proceeds at once. Once the helper has answered for a
body, a later error stays a transient failure of a working helper and
keeps waiting (R8 M5); held / busy / live are unchanged (review
K132345-helper-error-blocks-boot).
On the module-init repair path the lock body probed our own creation time
with a synchronous `ps` (macOS) and every 25 ms contended poll re-probed the
holder (ps, or a PowerShell of up to 15 s on Windows) on the Electron main
thread. Our own creation time now comes from process.getCreationTime() when
Electron provides it (ownCreateTimeSync), and lockHolderIsLiveSync takes a
creation-time probe the wait caches per pid: liveness is still re-read every
poll, the judging rule is unchanged (review K132345 P3 repair-lock-sync-probe).
The embedded Python and PowerShell judges inlined 0.005 / 1200 / 2.0; they
now interpolate OWN_CT_EPSILON_S / V1_MAX_AGE_S / CREATE_TIME_TOLERANCE_S
from update-marker-judge.ts (which the corpus test pins), so no copy can
drift. The PowerShell judge's process facts and clock moved into
Get-MarkerProcessFacts / Get-MarkerNow (same rule) so it is now replayed
against update_marker_corpus.json wherever pwsh/powershell is on PATH
(review K132345-judge-constants).
attachOrReserveSpawn retried an unreadable /api/health identity for the
whole HOST_SPAWN_GATE_STALE_MS (60 s) budget every 500 ms and re-ran
`git rev-parse HEAD` (5 s timeout) on every round before falling back to
the token attach main always did. The local commit is now resolved once per
attach call, and an UNCONFIRMED identity gets its own HOST_IDENTITY_RETRY_MS
(3 s) budget from first sight: a briefly slow health check is still retried
instead of spawning a second backend beside it, and a definitive mismatch is
still refused (review K132345-attach-identity-retry).
What: waitForManagedUpdateOperations takes an optional timeoutMs and answers
false when operations are still pending at the deadline (nothing is
dropped: they keep running under their own registration). The apply path
joins through joinManagedUpdatesForApply, bounded at 120 s; still pending,
apply returns ok:false 'managed-update-running' without resolving a
strategy or spawning the hand-off. before-quit keeps the unbounded join.
Why: an unresolved or late-registered managed operation parked applyUpdates
forever; the bound gives apply its own deadline while still never starting
local mutation (or the scripts' Desktop-exit deadline) under a live remote
writer.
Review: andrexibiza 5413004252 H3 (apply half; the quit half is recorded
as a DECISION: bounding before-quit means abandoning the observer/restore
transaction, which needs a durable remote-work hand-off design).
Red: start sha fails both new managed-ssh-update tests (join still parked
at 2 s; no joinManagedUpdatesForApply). Green: managed-ssh-update 37/37.
main.ts: 19823 -> 19829, still below the start sha's 19835.
What: processCreateTime (the async probe every marker judgement uses) no
longer falls through to processCreateTimeSync: Linux reads /proc with
CLK_TCK fetched once by async getconf, macOS asks `ps -o lstart=` through
async execFile, Windows keeps its async CIM call. hostJudgeEnv's isAlive is
async and reads the macOS process state through posixProcessStateAsync;
JudgeEnv.isAlive may return a Promise. psCreateTime/processCreateTimeSync
stay only for the module-init repair lock, which cannot await.
Why: the gate polls on Electron's main thread while a marker exists, and
the sync path blocked it on execFileSync ps (5 s timeout) on macOS, on
getconf on Linux, and on PowerShell (15 s) wherever the sync variant was
reached.
Review: kshitijk4poor 5411222842 (psCreateTime / PowerShell execFileSync on
the main thread).
Red: start sha fails both update-marker-async-probe tests (execFileSync
getconf / ps observed). Green: both pass; marker suites 192 + 1 skipped.
Native: the macOS branch runs here against procps `ps` with
process.platform spoofed; the Windows CIM branch is unchanged code and is
not executable on this Linux host.
What: update-marker-judge.ts exposes identityStateSync, the one copy of the
A7 rule-4 identity rule; the async judge reads the same facts and applies
it. processIsLiveSync (a second, drifting copy) is replaced by
lockHolderIsLiveSync, which runs that rule for the module-init repair lock
with the lock's mtime as the v1 ceiling anchor.
Why: the copy had no own-pid check and no v1 age ceiling, so a repair lock
naming our own pid without our creation time (a previous incarnation whose
pid we reused) or a live unrelated pid with no creation time was treated as
held forever and every launch threw 'Could not repair the desktop
installation ID'.
Review: kshitijk4poor 5411222842 (processIsLiveSync second copy).
Red: start sha fails both new desktop-installation tests ('...our pid
without our creation time...', '...live only within the v1 ceiling').
Green: desktop-installation 11/11, marker corpus/readonly/gate/handoff 176.
What: holdTicker({show, clear}) in update-marker-gate.ts owns a wait's hold
state machine (keep the probe's latest held state, consume it per poll,
show it after the unbroken grace, clear otherwise); the primary boot wait
in main.ts and waitForPoolUpdateClearance both use it, replacing the
copy-pasted held/holdGraceClock/show-or-clear code. UpdateHoldWire moves to
electron/update-hold-types.ts and src/global.d.ts re-exports it instead of
hand-mirroring it as DesktopUpdateHold (renderer users take UpdateHoldWire).
Why: the hold state machine was duplicated between the two waits and the
wire type had two definitions that could drift. c8fc9ae5b4a/b5e2fc62e3b had
already moved the controller ({show, clear} + registerUpdateHoldIpc) and the
pool wait out of main.ts and replaced the positional optionals with
updateGateDeps({onLiveMarker, onHeld, onOverride}); this finishes the split.
main.ts: 19835 -> 19823 lines (origin/main 19864). Same calls, same order.
Review: kshitijk4poor 5411222842 (shape: main.ts growth, duplicated hold
state machine, UpdateHoldWire mirror).
Tests: update-marker-gate.test.ts holdTicker (red on start sha: no export).
What: attachOrReserveSpawn re-validates a ready backend whose /api/health
identity stayed unreadable (timeout, transport error, non-2xx, bad body)
for the whole wait budget with the token-only handshake instead of taking
the spawn gate. A backend that answers a different commit, or answers
without one, is still refused.
Why: d4e0c1347d5 made a transient read 'unknown' and retried it, but at the
end of the budget the loop fell through to the spawn reservation, so a
correct but persistently slow /api/health still produced a second backend
beside the first. Only a definitive non-matching commit is a mismatch.
Review: kshitijk4poor 5411222842 (host-attach identity read regression).
Red: start sha fails 'an identity unreadable for the whole budget keeps the
token attach' (reservation taken). Green: backend-discovery 21/21.
desktop-installation.ts::reclaimDeadRepairLock read the lock, judged its
holder dead, re-read and compared, then unlinked BY NAME: a peer that
reclaimed the same dead lock and created its own in between (after our
compare, before our unlink) lost its fresh lock, letting a third launch
into the repair section beside it. Reclaim now renames the lock to a
private `.reclaim` sibling (the atomic claim: one reclaimer wins it),
unlinks it only if the moved file still holds the bytes judged dead, and
otherwise links it back under the name without clobbering a newer lock
(rename back where the filesystem has no hard links). Never throws, as
before.
Also fix global.d.ts's DesktopUpdateHold comment: UpdateHoldWire lives in
electron/update-hold-wiring.ts, not main.ts.
Review G5.
host-backend-attach.ts (fetchBackendCodeIdentity) refuses to attach to a
backend whose boot commit differs from its checkout, keyed on `commit` in
the public GET /api/health body -- the guard that keeps a serve that
outlived `hermes update` from being re-adopted. Nothing pinned the field:
renaming or dropping it would silently turn every attach into "unknown
commit". The test drives the real route through the web_server app and
pins presence (null off git) and the value source,
get_version_info().commit. Proven RED by renaming the field.
Review G4.
HUD and session windows mount UpdateHoldOverlay and pull the boot state
through getBootProgress on mount, which returned main's bootProgressState
including updateHold. Boot-progress pushes -- the one that clears the hold
included -- go to mainWindow alone (broadcastBootProgress), and
Quit/Check again/Start anyway refuse any non-primary sender, so a window
that mounted the blocked screen from its snapshot kept it forever with no
working way out.
The `hermes:boot-progress:get` handler moves into registerUpdateHoldIpc
(next to the other primary-sender rules; main.ts passes the state getter,
net zero lines) and strips updateHold for every sender that is not the
primary window. The overlay itself is unchanged: the primary window keeps
receiving the hold and its clearing exactly as before.
Review G3.
REMOTE_MARKER_JUDGE_PY::marker_ct parsed `ps -o lstart=` with local-time
time.mktime, while update_lock's stdlib creation time asks ps for UTC
(TZ=UTC0) and parses with calendar.timegm. In the repeated DST hour the
local parse is ambiguous (glibc picks the DST reading), so an owner that
started in the second 01:xx on a macOS/BSD remote came out an hour off
its recorded ct and was judged dead -- a live owner must never be judged
dead (invariant 4). Use the same UTC read and whitespace-normalised parse.
Review G2.
REMOTE_MARKER_GATE_PY (POSIX) and atomicWindowsSpawnCommand (Windows)
unlinked a marker judged dead without probing the remote CHECKOUT lock,
so an SSH `serve` could start beside a killed updater's completion/build
child that still held it -- the reclaim was not conditional the way
update_lock._reclaim_dead is (judged dead AND checkout lock free, under
the marker mutex; invariant 4).
Both gates now probe the checkout lock before deleting and answer HELD
(kept marker, refused startup) when it is held:
- POSIX: flock LOCK_NB on update_lock.checkout_lock_path (git common dir
hermes-update.lock, else <root>/.hermes-update.lock), taken and dropped.
- Windows: FileStream.Lock(1048576, 17) -- the owner byte plus the 16 R5b
lease bytes a refused completion child keeps after its owner dies.
A lock file that exists but cannot be opened/probed counts as held.
Checkouts probed: <install root>/hermes-agent plus the checkout of the
located hermes executable (POSIX, symlinks resolved) / runtime python
(Windows). The read-only Windows relaunch probe answers HELD too, so it
never reports CLEAR for a marker the spawn would then refuse to delete.
Review G1.
Code-health FILE_LINES ratchet on electron/main.ts (20005 > 19864, main's
value; now 19835). Moved, each with its comments, behind small host objects:
- the detached hand-off result report (log line / dialogs) ->
update-handoff-report.ts reportHandoffResult
- relaunchIntoSwappedBundle + BUNDLE_SWAP_RELAUNCH_FLAG/_FAILSAFE_MS ->
bundle-swap.ts
- the pool/profile backend's update wait -> update-hold-wiring.ts
waitForPoolUpdateClearance
- UPDATE_WAIT_TIMEOUT_MS/_POLL_MS and UPDATE_HANDOFF_DWELL_MS -> update-gate.ts
- repairMacUpdaterHelper -> updater-process.ts
Same calls in the same order, same log lines, dialogs and timeouts.
Behaviour unchanged.
Code-health FILE_LINES ratchet on the desktop i18n catalogs. Following
main's per-area pattern (ar_boot.ts composed by ar.ts), the whole `boot`
area (including this stack's boot.updateHold keys) moves to
<locale>_boot.ts per locale, composed back as `boot: <locale>Boot.boot`,
and its type to types_boot.ts (BootTranslations). Same keys and strings;
locales/_keys.desktop.json verifies unchanged. Behaviour unchanged.
Code-health FILE_LINES ratchet on use-gateway-boot.test.tsx (2991 > 2955).
This PR's R8 M6 update-hold case moves to use-gateway-boot-update-hold.test.tsx
with a minimal desktop bridge, and the shared FakeWebSocket stand-in moves
to src/test/fake-gateway-socket.ts for both files. Test behaviour unchanged.
Code-health FUNC_LINES ratchet (825 > 824, main's value). The generic
toast route-intent effect and its store/seen-ref move into a
useRouteRequestNavigation hook in the same module; same effect, same deps.
Behaviour unchanged.
Code-health CC ratchet (37 > 32, main's value). The staged Tauri updater
spawn + marker pre-write and the dwell/settle decision move into closure
helpers next to planHandoff/confirmScriptHandoff; same calls, same order,
same log lines and results. Behaviour unchanged.
Code-health CC ratchet (24 > 20 for new code). The marker stat, the
re-ask-due test, the helper re-ask (with the R8 M5 unsupported->error rule
and transition logging) and the once-per-hold override log move into module
helpers; the poll keeps the same order of calls (including lazy now())
and the same verdict handling. Behaviour unchanged.
Code-health CC ratchet (22 > 20). The contended-lock poll, the corrupt-file
replacement and the repair-lock release move into named helpers in the same
module; control flow, error propagation and lock compare-and-delete are
unchanged.
What: the marker gate probe config, blocked-screen state + rendering, the hold grace clock shared by the boot and pool waits, and the three update-hold IPC handlers move to electron/update-hold-wiring.ts; the checkout-HEAD identity read moves to host-backend-attach.ts. main.ts keeps call sites only (stack growth there 20162 -> 20004 lines vs base 19864).
Why: AGENTS.md puts new behaviour in topical siblings, never appended to the main.ts facade. Mechanical, no behaviour change; the whole file is not sharded.
Evidence: tsc -p tsconfig.electron.json clean; eslint clean; update-marker-gate/update-gate/update-gate-cancellation/backend-discovery/host-published-token/update-handoff-ack 59/59.
What: fetchBackendCodeIdentity now rejects on timeout/network/non-2xx/unparsable body and returns null only for an answered body without 'commit'; attachOrReserveSpawn treats a ready-but-unidentified record as unconfirmed and keeps re-reading it instead of taking the spawn gate (bounded by the existing wait budget).
Why: a slow /api/health was read as a definitive mismatch, so a Desktop spawned a second backend beside its own.
Red: pre-fix host-backend-attach.ts fails 'a transient identity read failure retries the running backend…' (reservation taken, no attach). Green: passes; backend-discovery + host-published-token 23/23.
What: one shared remote marker judge (remote-update-marker-programs.ts: system-python REMOTE_MARKER_JUDGE_PY/GATE, PowerShell WINDOWS_MARKER_JUDGE_PS) ports the corpus parse (ct:, delegate:, run:) and liveness rule (pid alive at |ct-actual|<=2s, else v1 1200s ceiling); the POSIX relaunch probe, POSIX spawn wrapper, managed-SSH observer, Windows relaunch probe and Windows spawn all use it. Both SSH spawn sidecars move from <marker>.mutex to <marker>.lock (POSIX flock, Windows FileShare.None, bounded 10s) and a dead claim is unlinked only inside that hold.
Why: the remote readers full-matched 'pid\nstarted', so every v2 marker was UNCERTAIN and a dead v2 claim refused SSH startup forever (Windows spawn refused on any marker), and no updater locks .mutex, so remote spawns were never serialized with the updaters' claims.
Red/green: pre-fix remote-lifecycle.ts fails the dead-v2 probe test ('unreadable or malformed') and the <marker>.lock race test ('Missing expected rejection'); pre-fix managed-ssh-update.ts reports a live v2 delegate as 'malformed'; all green after, plus the 50 corpus judge cases through the embedded Python.
Review R8 M6 (D3 "bounded, recoverable"). Only the primary boot wait published
its hold, and the hold IPC refuses when none is shown, so a profile backend that
met a hold waited forever with a single log line: a remote primary's
forced-local profile, or a profile switch after a local primary had booted
(e.g. a CLI `hermes update` that leaked a git child).
- Every blocked wait now registers its hold on one board (update-marker-gate
UpdateHoldBoard): the primary boot wait's hold wins, otherwise the first
blocked pool wait's is shown. A pool wait publishes it past the same 5 s grace
and only touches `updateHold`, never the window's boot phase; the primary's
own clear no longer drops a pool hold.
- Check again / Quit / Start anyway act on the shown hold id as before
(primary window only, confirmed, logged); Start anyway releases that pool wait.
- The renderer applies `updateHold` from boot progress after boot has completed
(it ignored every post-boot progress payload), so the screen also appears
over a running app.
Review R8 M5 + m7 (D3: the gate never opens on its own while an update holds
the install).
- M5: a marker body this process already saw block (held/busy/error/live) no
longer clears when the helper answers `unsupported`. That answer only means
the checkout's script is missing or unreadable for a moment (git unlinks and
rewrites a changed posix.sh; Windows sharing violations); it now counts as
`error`, keeps the gate closed and is re-asked. A script that exists but
cannot be read (anything but ENOENT) is `error`, never an older checkout.
main's reclaim is now exactly runMarkerHelper('reclaim'), which owns that rule.
- m7: the hold id mixes in the marker file's inode and mtime, so two empty (or
byte-identical) markers from different updates are different holds and a
Start anyway over one never passes the next.
- m7: helper verdicts are validated per op: `foreign`/`withdrawn`/`taken` from
reclaim (or reclaim's words from withdraw) are `error`, not clearance.
- m7: a wait that ends because the user chose Start anyway is reported as an
override (onOverride) and skips the bundle-swap relaunch, instead of being
logged as "update finished".
Review R8 D3. A verified `held` verdict (the update exited, a process it
started still holds the checkout lock) used to stop blocking after 7200 s and
let the local backend start while that process could still be writing the
install. The gate now never opens on its own while an update hold lasts.
- update-marker-gate.ts: no held ceiling. Each blocking state carries a stable
hold id (sha256 of the marker body) and `blocking` (held/busy/error, not a
live pid). `allowStartOverHold(id)` is the only way through while the hold
lasts, scoped to that exact marker body and process-wide (pool waits honour
it; a new body blocks again). `requestHoldRecheck()` makes every probe
re-ask the script helper on its next poll. The marker is never touched.
- main.ts: past a 5 s grace the boot shows the blocked screen (boot progress
`updateHold`): what holds the install, since when, last check. IPC from the
primary window only: recheck, quit, and start-anyway, which needs
`confirmed: true` and the hold id on screen, logs a USER OVERRIDE line and
flushes desktop.log. The non-blocking "started anyway" dialog is gone. Pool
waits log the hold once and wait for it to end or the primary's override.
- Renderer: UpdateHoldOverlay with Check again, Quit Hermes, Open logs and a
two-step Start anyway (confirmation copy says what can break). en/de/fr/es.
Tests: the ceiling cells are replaced by two invariants (held stays closed 10x
past the old ceiling and Retry re-asks at once; a confirmed Start anyway opens
only its hold, for every wait, and a new body blocks again). Both RED on
8855353024e, GREEN here. A real-process probe (SIGKILLed UpdateLock updater,
custody child holding the real checkout flock, real posix.sh) with the clock
jumped 3 h: the old gate opens, this one stays closed until Start anyway.
(cherry picked from commit 324a489259ab90e1f7158dcc2fa7d1c8a2da20f8)
A dead marker whose checkout lock a leftover process still holds (`held` /
`busy` from the script helper) parked the Desktop with no deadline (review R6
m7): a lock leaked into a long-lived process held start-up hostage while the
scripts themselves give up after RELEASE_WAIT_S = 7200 s.
The gate's marker probe now counts a held wait from the marker's line 2 (the
last time an owner wrote it), or from the first sighting in this process for
an unreadable line 2, and stops blocking after HELD_CEILING_MS = 7200 s: it
logs it once, reports it through onHeld, and leaves the marker untouched
(A7: the Desktop never mutates it). While held, boot progress names the
update process that exited and the time left; past the ceiling a
non-blocking dialog tells the user what still holds the install and how to
move on. A helper `live <pid>` keeps no ceiling (C1 rule 3). The ceiling is a
test seam option, not an env var.
(cherry picked from commit ac513e33ce0f5c99338d51607f894cf0dc3b4a8c)
The corpus now pins started_at to u64 (any digit count): Rust already
rejected 18446744073709551616, the TS judge took it (as an imprecise
Number). parseUpdateMarker compares line 2 as a BigInt, which parses any
digit string exactly and cannot throw behind the digits-only check.
(cherry picked from commit 9ef0529f1b2f531238b02977347f4dc8b8038531)
The boot/pool gate no longer deletes a dead marker. A dead or malformed
marker is routed through the checkout script's `reclaim` helper when the
script speaks hand-off protocol 2: held/busy/live keep the gate closed
(a completion can still hold the checkout lock after the script's child
died), reclaimed/absent open it, and an older checkout without the helper
proceeds as before minus the deletion. The helper is asked once per
distinct dead body per wait; held is re-asked every 5 s. The cached
create-time probe per wait is kept.
The recovery hand-off keeps its read-only conflict check and makes the
staged-updater pre-write exclusive-create only (logged when skipped).
(cherry picked from commit 60ddb61fddfda6af46954f42a19ab133520faf68)
A7 rules 3/4/6, R3/R4/R5 (Desktop side).
- update-marker-judge.ts: parse + judge exactly per the shared corpus
(tests/fixtures/update_marker_corpus.json): u32 pids, pid 0 dead, BOM/CR/
space-tab trimming, delegate/run on any line >= 4 (first wins), own pid
ours only at the exact incarnation (5 ms; no ct = previous incarnation),
2 s for other pids, v1/unreadable ct live only inside 1200 s.
- update-marker.ts never deletes or rewrites the marker: compareAndDelete
is gone; readLiveUpdateMarker/updateHandoffConflict are read-only; the
bridge and the staged-updater pre-write are a single exclusive create
that reports an existing dead marker / stale bridge of ours instead of
reclaiming it. Our own bridge is `ours`, not someone else's update.
- waitForHandoffClaim acknowledges only a LIVE line-1 owner that is not the
Desktop: protocol 2 requires a ct-verified ('match') identity and the
run id the Desktop passed; legacy scripts require liveness and the
started_at they echo. A claimant that died before the ack is not taken
(the reviewer's dead-daemon probe flips taken:true -> taken:false).
- checkout.ts protocol 2 (SPEC 4a): run id desk-<pid>-<b36 ms>-<hex4>,
bridge carries run:<id>; EEXIST -> live foreign refuses, dead/malformed
-> helper reclaim, stale bridge of ours -> helper withdraw with its run,
then one retry; --handoff-run / -HandoffRun passed; timeout -> helper
withdraw (`taken <pid>` = late success) then killHandoffTree. Legacy
scripts (SPEC 4b): no bridge, no run arg.
- update-marker-gate.ts: gate probe for R6 (wired into main.ts next).
Tests: corpus consumer (all 43 judge cases) + every export leaves every
corpus marker byte-identical; A7 rule 3 read-only cells and R5/legacy ack
cells with real child processes (fail on the previous head: ENOENT /
taken:true); checkout protocol-2 flows against a real fake helper script.
(cherry picked from commit 102ba6f4a27985ceccca06a47d528b6e1cca4cda)
A7 rule 3 makes the checkout script the only marker mutator Electron may
use. Add the runner for `posix.sh --marker-op <op>` / `windows.ps1
-MarkerOp <op>` (reclaim|withdraw, optional desktop pid + hand-off run),
HERMES_HOME set, hidden, hard 20 s bound (process group killed on POSIX),
and a strict one-line verdict parser (absent|reclaimed|held|busy|
live <pid>|withdrawn|taken <pid>|foreign; anything else, a nonzero exit or
a timeout is `unsupported`). Protocol negotiation reads the script text for
`^# hermes-handoff-protocol: (\d+)\s*$`; < 2 is a legacy script.
Tests run a real fake script process (argv, env, exit 64, garbage, two
lines, timeout, missing script) and pin the Windows command line through an
injected spawn.
(cherry picked from commit f1ef8fed94a510445b8417d9083618a6ccde15a7)
Contract C2: a script hand-off is confirmed only when the script takes the
Desktop's bridge marker in its own name, not when the launcher exits 0. The
channel hand-off test mocked spawnUpdaterProcess with a script that never
claimed, so apply() waited out the 20 s claim window and correctly reported
updater-spawn-failed. The fake now takes the marker as a live foreign pid,
and the test releases it after each hand-off (the update finished) so the
next apply does not see a live foreign owner. Claim timeout lowered to 2 s.
(cherry picked from commit fb43b97f0f501a2693130b4d8828014a380ae211)
plain Node has no process.getCreationTime, so on Windows the bridge claim
from tooling/tests was written without a ct line (v1). Found by the wine2e
Windows run of update-marker.test.ts (V4 cell).
(cherry picked from commit 5b530c7579b41dc413448c616125de16ee6ce5ae)
runs only on win32 (the wine2e lane): csrss.exe is access-denied to Get-Process
.StartTime; a marker whose pid a SYSTEM process reused must read DEAD.
(cherry picked from commit d10a47b71402140c4e474d3b1b60d2b6ed5d7ec6)
Round-2 review findings on the Electron side of contract C1/C2:
- A1/MAJOR-1: read a foreign pid's creation time on Windows through CIM
(Win32_Process.CreationDate, limited query rights) instead of
Get-Process .StartTime, which is access-denied for SYSTEM/elevated/
other-user processes. The update gate memoises one probe per pid per
wait (no powershell spawn per 1 s poll). A live pid whose creation time
cannot be read is live only inside the 20-minute ceiling, so a reused
pid can no longer park Desktop boot forever. macOS ps lstart is printed
and parsed as UTC.
- r1 M1: the staged Tauri updater pre-write names the spawned updater's
pid AND creation time (v2), so the updater adopts a v2 claim, no age
ceiling applies, and its hermes update child can add the delegate line.
- A3/m1: claims are published by writing a tmp sibling and hard-linking it
into place (EEXIST never replaces); O_EXCL+write remains only as the
no-hardlink fallback, and readers treat a 0-byte marker younger than 5 s
as live. Dead writers' tmp siblings are reclaimed.
- A2/MINOR-4: positional parse identical to the contract (BOM, CRLF,
integer started_at else malformed, bad ct => v1, bad delegate ignored).
- MINOR-3: on hand-off timeout, a missed compare-and-delete that finds the
script already took the marker counts as a started hand-off; otherwise
the Desktop kills the launcher tree it spawned (POSIX process group;
Windows: the wrapper's children carrying our -DesktopPid).
(cherry picked from commit 0615bd7c0565166afec2dff1f7b1ef5e835af56c)
The Electron side of the update-marker contract (C1/C2):
- update-marker: parse v2 (`ct:` creation time, `delegate:` line); an owner
is live iff its pid is alive, not a zombie, and its creation time matches
within 2 s (unknown = live). A live v2 owner is never aged out; the
20-minute ceiling now applies only to v1 markers. The marker is live when
the owner OR the delegate is live. Every delete is compare-and-delete.
- The bridge marker names Electron's own pid + creation time, claimed by
exclusive create BEFORE the spawn (it named the cmd.exe wrapper, dead
within ms). A live foreign owner refuses the hand-off.
- The hand-off counts as started only when the script takes the marker
within 20 s; a wrapper's exit 0 no longer quits the app. Otherwise the
Desktop stays up, restarts its backend, shows the error and
compare-deletes its bridge marker.
- update-gate: a failed receipt no longer outranks a live marker, and a
live marker owner keeps the boot parked past the old 20-minute deadline.
- handoff-result: parse before unlink, keep malformed files as .corrupt,
discard a result whose started_at belongs to another run, surface
ok:true warnings as a non-blocking notice.
- desktop-installation: the repair lock names its holder and a dead
holder's lock is reclaimed instead of throwing on every launch.
(cherry picked from commit 5d7f36bfaec2213e5c959ad1e0d21d514b9f94b7)
A CLI/launchd `hermes serve` that outlived an update kept serving the old
code; the new Desktop attached on token alone, got 503 "Restart required",
and Restart re-attached the same stale process. `/api/health` now publishes
the boot commit and attach falls to the next ladder rung (next record, then
spawn) unless it equals the checkout HEAD. Unknown Desktop identity
(packaged/non-git) keeps the token-only attach.
(cherry picked from commit e59c7e64bdf417253d3ed9f85710f70adbba3e11)
* fix(bedrock): route GPT-6 family and prefixed OpenAI inference profiles, size their 1M window
An enterprise deployment on provider bedrock with model us.openai.gpt-6.1-sol
compressed at 96,000 tokens with a 400K compression cap configured. Three gaps,
all on origin/main c53536929e:
1. agent/bedrock_adapter.py:77-79 BEDROCK_OPENAI_RESPONSES_MODEL_IDS held the
GPT-5.5 and GPT-5.6 IDs only (unchanged since 16476fad10). AWS now publishes
cards for GPT-6 Astra, GPT-6 Sol, GPT-6 Luna and GPT-6.1 Sol.
2. agent/bedrock_adapter.py:165-167 is_openai_bedrock_model was an exact set
match, so us.openai.gpt-5.6-sol returned False while openai.gpt-5.6-sol
returned True. Every cross-Region inference profile of a Bedrock OpenAI model
was misclassified.
3. agent/bedrock_adapter.py:1169-1195 BEDROCK_CONTEXT_LENGTHS had no GPT-6
rows, so get_bedrock_context_length fell to the 128,000 default silently
(the fallback WARNING fired only for application-inference-profile ARNs).
agent/context_compressor.py:1181 applies the 75% small-window floor, which
is exactly the observed 96,000.
Per the AWS model cards (model-card-openai-gpt-6-1-sol.html and siblings):
the bare openai.* ID is served in-Region by bedrock-mantle (Responses, Chat
Completions, no geo or global IDs), and the us./global. profile IDs are served
by bedrock-runtime where Converse is listed as supported. GPT-6.1 Sol states a
1M context window with 131,072 max output tokens, the other cards state
1,050,000. 272K on these cards is the long-context pricing tier boundary, not
the window.
Changes
- parse_bedrock_openai_model_id(model_id) -> (bare_id, profile_prefix) | None.
is_openai_bedrock_model is True for every endpoint form of an allowlisted ID,
GPT-OSS stays False. New bedrock_openai_uses_mantle is True only for the bare
in-Region ID. Both callers (hermes_cli/runtime_provider_backends.py primary,
agent/auxiliary_client.py auxiliary) route Mantle on that predicate and keep
prefixed IDs on Converse via bedrock-runtime, the path AWS documents for them.
The Claude profile-prefix regex now derives from the same prefix tuple.
- GPT-6 generation added to the allowlist, the static picker catalog and the
context table. OpenAI rows sized from the cards (6.1 Sol 1,000,000, others
1,050,000). The table/metadata pairing test covers the new rows.
- get_bedrock_context_length logs one WARNING per model ID per process when
the static default is used for any ID, naming the model, the value, that it is
a fallback, and the model.context_length override.
- website/docs/guides/aws-bedrock.md: route table, model table and the
cross-Region note describe both endpoint forms.
The empty compression summary the deployment also reported is not addressed
here. Converse is a valid path for the prefixed ID, so routing does not explain
it, and no request trace exists yet.
* chore(bedrock): keep auxiliary_client.py within its code-health line cap
Rewrap the _build_bedrock_client docstring back to two lines so the file does not grow past its value on main (code health ratchet, FILE_LINES).
---------
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
On main (#132386) a --prepared child that exits without a result file is
a lost completion: _prepare settles it as owed and re-arms the tail for
the next launch. The L3 test's fake child returned 0 and wrote nothing,
so after the rebase the recorded events gained a trailing
arm_completion from that settle path. The fake now behaves like a
successful child and writes its result; the ordering assertion the test
exists for (lease first, then everything that writes the checkout) is
unchanged.
main.py may only shrink vs main (code-health FILE_LINES); this branch grew it
by 9 lines. _probe_container and _exec_in_container (the NixOS container-mode
re-exec, 96 lines) move verbatim to the topical sibling
hermes_cli/main_container.py; the entry point is now public
(exec_in_container) and main() late-imports it at its only call site. The
test import and the managed-runtime resolution allowlist entry follow the
move. No re-export shim. Behaviour is unchanged.
Two mocked tests drive _legacy_kill_process_tree down its shared-group
branch with a MagicMock proc whose pid is 999. That branch snapshots
psutil.Process(proc.pid).children(recursive=True) and SIGKILLs each
descendant, and psutil was never patched: the tests killed the real
descendants of whatever the machine runs as PID 999. On macOS runners
PIDs are allocated system-wide, and when 999 was an ancestor of the
test worker the file died mid-run (CRASHED, killed by SIGKILL), which
is how it surfaced on a PR whose diff never touches the browser tool.
Both tests now run against a fake process table, and the shared-group
test asserts the snapshotted descendant is killed individually (the
contract it was written for).
Live repro: inside an unprivileged user+PID namespace with PID 999 an
ancestor of the test runner, main's file reports CRASHED (killed by
SIGKILL); with this change it passes 9/9.
checkout_lock_held answered "free" when the lock file existed but could not be
opened. The hand-off scripts (marker.sh / marker.ps1) and the Desktop probes
(checkout_held, WINDOWS_CHECKOUT_LOCK_PS) answer "held" for the same file, so a
Python reader could reclaim a marker or admit a launch the others would park.
Only a missing lock file is free now; an existing file that cannot be opened
answers held. A lock call that fails outright (ENOLCK on NFS without lockd,
EOPNOTSUPP on some SMB shares) still answers free: nothing can hold such a
lock, and the interrupted-pull repair relies on that to run unguarded there.
Both callers that act on "held" are bounded (_reclaim_dead keeps the dead
marker; the acquire path waits its contention budget).