- challenge-window.ts uses createWindowOpenHandler (deny + sanitised
origin log, the link-title window's own hardening) instead of an
inline deny.
- The renderer's outcome type is the generated contract's
FreeTierChallengeResultParams['outcome']; global.d.ts uses the main
process's ChallengeOutcome instead of a third hand-copied union, so a
drift between the window and the contract fails the renderer typecheck.
- runFreeTierChallenge takes the gateway requester itself and builds the
free_tier.challenge_result call, so lifecycle.ts and free-tier.ts stop
each hand-rolling a reporter closure.
No behaviour change.
The main process de-duplicated challenge windows by URL alone, and
remembered settled outcomes by url:attempt. If an optional (measuring,
never-revealed) run of a ticket was in flight, or had just ended
`failed` on #interactive, and the same ticket turned required, the
required ask got the optional run or its remembered `failed`, so no
window was ever shown for the human to clear. Key both the in-flight
run and the settled memory on URL + required.
NAS may answer POST /api/anonymous/token with 428 challenge_required and a
portal URL. Hermes implements the one `browser-v1` primitive: open the URL
(hidden where it can), poll /api/anonymous/challenge/status, exchange again.
Everything the page does (BotID, in-browser proof of work, an interactive
Turnstile fallback, its copy) belongs to the portal and changes without a
Hermes release. The mint stays authoritative throughout.
Python (hermes_cli/anon_challenge.py)
- the exchange reports surface (desktop / cli / gateway), version and the
browser-v1 capability; a UA of the documented shape goes on every call
- the wait runs outside the auth-store and shared-store locks, under a
per-profile work lock; only a caller someone is waiting on waits — the
keepalive tick and a status paint announce the challenge and return
- 403 signin_required / access_denied and any 428 this version has no
primitive for become a terminal sign-in error, scoped to the token
exchange; sign-up and the promotion endpoints keep their previous verdicts
- status-poll 429 / 5xx / transport are blips; a 4xx is a verdict (the
credential is gone); a second 428 starts a fail-fast cooldown so a boot
burst of token readers does not each re-mint and reopen a window
- the pending challenge is published as the free_tier.challenge event and
on free_tier.status for a client that connects later; the desktop reports
how each window ended (free_tier.challenge_result) so the waiter re-mints
promptly, and an attempt counter lets a fresh foreground attempt reopen a
window the user closed without a status replay reopening it
- terminal: print the link, open a graphical browser once per ticket; a
gateway process never opens one; control characters never reach the tty
- challenge URLs are only ever opened on the portal origin the exchange hit
Desktop (apps/desktop/electron/challenge-window.ts)
- the page loads in a hidden, sandboxed window of its own partition, pinned
to the portal origin (will-navigate, will-redirect, off-origin commits end
the run); a phase is read only from a main-frame portal URL fragment
- #interactive reveals the window (never for an optional challenge), #done
and #failed close it; an HTTP error commit, a load failure and a crashed
renderer are retryable, a closed window is not; timers are bounded
- the renderer relays the event and the status read to one window per
attempt and reports the outcome once; a backend without the RPC is fine
Regression test for #131121: after subscribe -> unsubscribe (a row's
mount -> unmount), a second call with the same id must return a fresh
atom — the entry was released instead of being retained for the
window's lifetime — while identity stays stable while subscribed.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
CronJobCreate did not declare a repeat field, so Pydantic dropped it during
request parsing, and _create_cron_job_sync never forwarded one to
cron.jobs.create_job — a finite repeat count submitted via POST /api/cron/jobs
was silently stored as an unlimited recurring job.
- Add optional repeat field to CronJobCreate (int or CLI string form)
- Forward it from _create_cron_job_sync; non-positive counts 400, string
forms coerce through the shared normalize_repeat_value the CLI uses
- Add repeat?: number to the desktop CronJobCreatePayload type
- Route-level regression coverage: finite survives create+list, omission
stays unlimited, invalid counts 4xx with no job stored
The picker door (openSessionFromPicker — /resume overlay, ⌘K, artifacts)
only ran openSession, whose in-place path treats the already-active
session as a front-only no-op. The sidebar's own door always queues an
explicit requestSessionResume, which use-route-resume honours via its
explicitlyRequested branch to re-run resumeSession even when the session
is already active. Give the picker the same explicit reselect so clicking
the active session re-attaches the transcript and re-focuses the composer
(#62045) instead of doing nothing.
The marker probe (~8.3k chars encoded) was the second Windows SSH
command still over cmd.exe's 8191-char limit after the platform probe
moved to stdin: it died with 'The command line is too long' and the
relaunch gate could never observe CLEAR. Ride the same stdin transport
(powerShellStdinCommand) instead of splitting the probe, keeping the
C# no-follow marker reader and its fail-closed semantics intact, and
assert every emitted Windows probe command stays under the limit.
Co-authored-by: nikkoxgonzales <nikkoxgonzales@users.noreply.github.com>
Fixes#106716
Refs #106903
Avoid the Windows cmd.exe command-line limit by sending the PowerShell platform probe over SSH stdin instead of embedding a long encoded command. Keep probe statements newline-separated so PowerShell try/catch blocks parse correctly, and cover the real PowerShell path with regression tests.
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.
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).
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).
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.
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).
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).
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.
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.
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.
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.
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.