Commit Graph

7792 Commits

Author SHA1 Message Date
Teknium
4a29ef7105 test: the producer-receipt test follows the marker to the custodian the hand-off names
Since "an old Desktop never reads a dead owner while the POSIX hand-off dies", posix.sh hands the
update marker to a custodian process that outlives the hand-off and logs
"update marker names its custodian pid N (hand-off pid M)". The producer-receipt test still waited
for the marker to name the hand-off script's own pid and failed at the refresh assertion. It now
requires that log line for this hand-off and asserts the refreshed marker names that custodian.
2026-10-06 05:44:21 -07:00
Teknium
c3d19d497a fix: a failing withdraw helper is asked once, not three times
withdrawBridge re-asked every unsettled answer, so on the never-started
path a helper that errors (a hung or blocked interpreter) cost 3 x the 20 s
MARKER_HELPER_TIMEOUT_MS with the user waiting. Only `busy` (the sidecar
lock, usually brief) is re-asked now; every other answer stands at once
and the existing settled / quit decision is unchanged. The existing test's
"asked three times" for `error` encoded that cost and now expects one ask
(review K132354 P3 withdraw-retries-error).
2026-10-06 05:44:21 -07:00
Teknium
3d6f53aadc fix: a normal boot with no update marker spawns no marker helper
With no marker the gate ran the script's `reclaim` on every primary boot
and every pool/profile backend; on Windows that is a powershell.exe load
on the boot critical path (20 s timeout). The script answers "not held"
whenever no regular file sits at the checkout lock path, so the gate now
resolves that path exactly as marker.sh checkout_lock_path / marker.ps1
Get-CheckoutLockPath do (checkoutLockPath, replayed against the real
marker.sh for every layout) and skips the helper when it is missing.
Anything it cannot settle (an unreadable .git, a stat error other than
"missing") still asks the script (review K132354-helper-always-spawned).
2026-10-06 05:44:21 -07:00
Teknium
8bb74e0413 fix: the boot wait's run correlation lives in update-handoff-report, not main.ts
waitForUpdateToFinish kept the parked run's identity (marker line 2 and the
stable run id) as local state in main.ts and threaded both into
reportHandoffResult. That correlation is update behaviour, so it moves to
update-handoff-report.ts as parkedRunReport(): park(marker) on every live
marker the gate sees, report(host) once the wait ends, matching the result
to the last parked run. main.ts keeps only the composition calls and is
three lines shorter than the parent PR's main.ts.
2026-10-06 05:44:21 -07:00
Teknium
e065426cde refactor(desktop): the gate probe names what it asks the helper about
a0a6009a7ff taught liveMarkerProbe to route a missing marker through the
script helper, which pushed the probe closure past the cyclomatic cap.
helperSubject() now says what is asked about (a dead marker body or no
marker: hold id, owner pid, run identity, log label) and keepsGateShut()
holds the "no marker: only `held` blocks" rule. Behaviour unchanged; the
gate tests pass as they did.

Review 5411223284 (open thread: update-marker-gate.ts absent).
2026-10-06 05:44:21 -07:00
Teknium
e13fb204e9 fix(update): with no marker the Desktop gate still asks whether the checkout lock is held
The script helper's `reclaim` answered `absent` the moment the marker file
was missing, before the checkout lock was looked at, and the Desktop gate
(update-marker-gate.ts liveMarkerProbe) returned "nothing runs" for a
missing marker without asking the helper at all. An update that holds the
checkout lock before it publishes its marker, or after it retired it (the
CLI paths of the stacked PRs do both), therefore let the Desktop boot a
backend into a checkout still being written.

- marker.sh marker_op_reclaim_locked / marker-claim.ps1 Invoke-MarkerOp:
  no marker -> `held` when the checkout lock (owner byte + lease bytes on
  Windows) is held, else `absent`, under the same marker lock.
- update-marker-gate.ts (gate-lane file, additive): a missing marker is also
  routed through the helper; only its positive `held` keeps the gate shut
  (blocked screen, Retry, Start anyway as for a dead marker). Any other
  answer, `error` included, proceeds as before, so a broken script never
  blocks a markerless boot.

The gate test "a live owner never reaches the helper; an absent marker
neither" pinned the missing check for the absent half; that half is now the
new "no marker: only held keeps the gate shut" case.

Review 5411223284 (open thread: update-marker-gate.ts ~193 / helper absent).
2026-10-06 05:44:21 -07:00
Teknium
a10792bb5b fix(desktop): a hand-off result still correlates after the heartbeat moved marker line 2
When the gate saw a marker without a `run:` line (an older producer, or a
boot that never identified the run), handoff-result.ts matched the result's
started_at against marker line 2 for exact equality. The scripts' 300 s
heartbeat (marker.sh marker_refresh_locked, marker-claim.ps1
Update-MarkerHeartbeat) rewrites line 2 with the current time while the run
lives, so for any update longer than 5 minutes the compare failed and,
because the file is consumed before the compare, the result was gone.

Line 2 is now matched as a time inside the result's run: equal to
started_at, or after it and no later than finished_at. A result from an
earlier run finished before this run began and is still discarded. The
consume-before-compare order stays: a result that is genuinely another
run's must not resurface on every later boot.

Two existing assertions used an "other run" with started_at 100 and
finished_at = now against line 2 = 200, i.e. line 2 inside that run's
lifetime, which is exactly the heartbeat case; they now use an other run
that finished before the parked line 2 (same intent, realistic times).

Review 5411223284 regression 2.
2026-10-06 05:44:21 -07:00
Teknium
b08a617bfa fix(desktop): a hand-off whose bridge could not be withdrawn quits instead of restarting the backend
When the hand-off script misses the 20 s claim window, the Desktop asks the
script helper to withdraw its bridge marker. Any answer but `taken` used to
report "the updater did not start", kill the spawned tree and (on Windows)
restart the backend. A `busy` or `error` answer leaves the bridge in place,
and the posix daemon re-exec'd under setsid is out of killHandoffTree's
reach: it adopts the bridge whenever it starts and runs `hermes update`
beside the backend this Desktop just restarted.

Only a settled answer (withdrawn / absent / foreign: no script can adopt
this run any more) now reports the failure and kills the tree. busy / error
/ unsupported are re-asked (3 attempts, 250 ms apart); if the bridge still
stands the Desktop quits as for a hand-off, so the next boot's update gate
judges the bridge or the script's claim under the script's lock.

The old "a timeout withdraws the bridge" case pinned `busy` -> failure,
which is the reported bug; it is replaced by busy/error -> quit (bridge
bytes untouched, no restart, no kill) and a withdrawn -> failure + kill case.

Review 5411223284 regression 1.
2026-10-06 05:44:21 -07:00
Teknium
3c6a7dfe41 refactor(desktop): split readAndConsumeHandoffResult into named phases
readAndConsumeHandoffResult was over the code-health CC target (21 > 20).
Extract its phases into module-private helpers in handoff-result.ts:
consumeHandoffResultFile (read, parse, rename-to-.corrupt, unlink),
handoffRunMismatch (run_id validity, run_id / started_at correlation; returns
the exact log line it used to emit) and toHandoffResult (field coercion).
Check order, log lines, and the JSON-`null` body path (still logs "no
finished_at") are unchanged. Behaviour unchanged.
2026-10-06 05:44:21 -07:00
Teknium
b5852b3187 fix(desktop): correlate handoff receipts with stable run identity
(cherry picked from commit 87c67de3778735837b73de8d2955f83624b2b449)
(cherry picked from commit bf3ca9f63cead72e51f22d4376abe5b3504d4bf2)
2026-10-06 05:44:21 -07:00
Teknium
3b4a891174 test(desktop): fakeSsh's spawn-payload comment joins the line it explains
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.
2026-10-06 05:17:45 -07:00
Teknium
b718aa79d5 fix: a leftover dead marker no longer hides the old-renderer warning
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).
2026-10-06 05:17:45 -07:00
Teknium
2f31792e82 fix: a failed update's live-looking v1 marker no longer parks boot 20 min
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).
2026-10-06 05:17:45 -07:00
Teknium
80fd4bb3e2 fix: a dead marker whose helper can never run no longer parks boot
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).
2026-10-06 05:17:45 -07:00
Teknium
b728467842 fix: the installation-id repair lock no longer spawns ps per poll
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).
2026-10-06 05:17:45 -07:00
Teknium
7f37b99793 fix: the SSH remote marker judges share the TS judge's constants
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).
2026-10-06 05:17:45 -07:00
Teknium
1aa43ca9be fix: a ready backend with an unreadable identity attaches within 3 s, not 60 s
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).
2026-10-06 05:17:45 -07:00
Teknium
c53aa5debd fix(desktop): a local update apply refuses instead of parking behind a managed SSH update
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.
2026-10-06 05:17:45 -07:00
Teknium
19027b1c75 fix(update): the update gate's marker judgement never spawns synchronously
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.
2026-10-06 05:17:45 -07:00
Teknium
ee1c1982f4 fix(update): the repair lock judges its holder with the marker judge's own rule
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.
2026-10-06 05:17:45 -07:00
Teknium
7b27ff08a6 refactor(desktop): one hold state machine for the boot and pool waits, one UpdateHoldWire
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).
2026-10-06 05:17:45 -07:00
Teknium
58d86507c3 fix(update): a backend identity unreadable for the whole wait keeps the token attach
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.
2026-10-06 05:17:45 -07:00
Teknium
ce4deb6a13 fix(update): repair-lock reclaim removes only the file it judged dead
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.
2026-10-06 05:17:45 -07:00
Teknium
d12e19510e fix(update): only the primary window's boot snapshot carries the update hold
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.
2026-10-06 05:17:45 -07:00
Teknium
cfc1ff32c3 fix(update): remote marker judge reads ps lstart in UTC like the Python judge
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.
2026-10-06 05:17:45 -07:00
Teknium
7417f3786e fix(update): SSH remote gates keep a dead marker while the checkout lock is held
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.
2026-10-06 05:17:45 -07:00
Teknium
73b813d018 refactor(desktop): move update-gate helpers out of main.ts into topical siblings
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.
2026-10-06 05:17:45 -07:00
Teknium
b960f9f73b refactor(desktop): split the boot catalog area out of en/de/es/fr and types
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.
2026-10-06 05:17:45 -07:00
Teknium
3a453d26c2 refactor(desktop): move the update-hold boot case to its own test file
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.
2026-10-06 05:17:45 -07:00
Teknium
170c6c46bc refactor(desktop): extract the route-request intent effect from ContribWiring
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.
2026-10-06 05:17:45 -07:00
Teknium
d3106bd7b6 refactor(desktop): extract the staged-updater spawn and hand-off settle from applyBody
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.
2026-10-06 05:17:45 -07:00
Teknium
1571b7ccf1 refactor(desktop): split liveMarkerProbe's poll into named phases
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.
2026-10-06 05:17:45 -07:00
Teknium
8d17cb1c97 refactor(desktop): split loadOrCreateInstallationId into lock-wait/replace/release helpers
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.
2026-10-06 05:17:45 -07:00
Teknium
61b69853ee refactor(desktop): move the stack's update-hold wiring out of main.ts (review desk-main-ts-godfile)
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.
2026-10-06 05:17:45 -07:00
Teknium
d1429fb013 fix(update): a ready backend with a briefly unreadable identity is retried, never doubled (review desk-identity-transient-mismatch)
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.
2026-10-06 05:17:45 -07:00
Teknium
ea5d052d8f fix(update): SSH remotes judge v2 markers under <marker>.lock (review A7-sidecar-not-shared, SSH-v2-marker-strands-remote)
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.
2026-10-06 05:17:45 -07:00
Teknium
01fcc6ec6f fix(desktop): a pool/profile backend blocked by a held update gets the blocked screen
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.
2026-10-06 05:17:45 -07:00
Teknium
dcc43f6950 fix(desktop): a held update never opens on a missing script or a misplaced verdict
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".
2026-10-06 05:17:45 -07:00
Teknium
74b5854ebd fix(desktop): a held update blocks boot behind a recoverable screen, never a timer
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)
2026-10-06 05:17:45 -07:00
Teknium
0b4eb2e8c3 fix(desktop): keep update gate closed on indeterminate helper results
(cherry picked from commit dadc696311c7b9bb945178abfe77111611c18d80)
(cherry picked from commit 8855353024e39c2c8ec7f211c333938b6779227a)
2026-10-06 05:17:45 -07:00
Teknium
472fc82e2b fix(desktop): update gate stops waiting on a held checkout at the scripts' 7200 s ceiling
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)
2026-10-06 05:17:45 -07:00
Teknium
6eb77da1ee fix(desktop): marker judge reads line 2 as malformed past u64::MAX
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)
2026-10-06 05:17:45 -07:00
Teknium
f32c448141 fix(desktop): update gate asks the script helper about dead markers (R6)
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)
2026-10-06 05:17:45 -07:00
Teknium
9d0b685538 fix(desktop): read-only marker, corpus judge, live+correlated hand-off ack
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)
2026-10-06 05:17:45 -07:00
Teknium
dfa539fa99 fix(desktop): script marker-helper runner and hand-off protocol negotiation
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)
2026-10-06 05:17:45 -07:00
Teknium
b5ce061f41 test(desktop): source hand-off test's fake script takes the bridge marker
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)
2026-10-06 05:17:45 -07:00
Teknium
3285e0a22c fix(desktop): own-pid creation time falls back to CIM outside Electron
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)
2026-10-06 05:17:45 -07:00
Teknium
7faaa006e9 test(desktop): Windows reads a SYSTEM process creation time through CIM
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)
2026-10-06 05:17:45 -07:00
Teknium
10d866b0d3 fix(desktop): marker liveness never waits forever, never sees half a claim
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)
2026-10-06 05:17:45 -07:00
Teknium
111483933b test(desktop): sort imports in the liveness tests
(cherry picked from commit e63ebf918c305a66a48dd9059b5518f3a0b2fd05)
2026-10-06 05:17:45 -07:00