Commit Graph

7819 Commits

Author SHA1 Message Date
Brooklyn Nicholson
9ab4a4fdbb fix(desktop): extract recovery-request toast handling into useRecoveryRequestToasts hook 2026-10-06 13:39:19 -05:00
Jakub Wolniewicz
5736ea3782 fix(desktop): decouple background queues from foreground connectivity 2026-10-06 13:39:19 -05:00
Brooklyn Nicholson
3f62bc5342 feat(desktop): full-width butterbar above the statusbar 2026-10-06 13:24:06 -05:00
Brooklyn Nicholson
a10faf9e5a feat(desktop): butterbar store — register notices from anywhere 2026-10-06 13:24:06 -05:00
ethernet
fa2dcb1077 fix(desktop): don't use xwayland on nvidia open kernel module
#127048 detected both modern nvidia open kernel drivers AND propetiary
drivers as the proprietary one, disabling wayland.

this fix looks for the "open kernel module" string in
/proc/driver/nvidia/version, and lets Wayland rendering continue to be
used.
2026-10-06 14:11:29 -04:00
Teknium
d3674513c8 docs(telemetry): the send explainer and Desktop consent window name the fresh-install note
The fresh-install receipt is kept from before the consent answer and counted
after an opt-in, so "data from before you opt in stays on this machine" was
false for it on the CLI send explainer and Desktop's consentWindow line in all
9 locales; only the pre-chat offer named the exception. Both now carry the same
"apart from the fresh-install note (noted on this machine and counted only once
you opt in)" clause. No i18n key changes; the CLI lines stay under 80 columns.
2026-10-06 11:10:43 -07:00
Teknium
4e0eae06e6 docs(telemetry): consent copy names install/update failure reasons and the local install note
CLI setup, the pre-chat consent offer and the Desktop collectedReliability line (all 9 locales,
no new key) now say update and install results carry a fixed-list reason and stage, and that a
fresh install is noted on this machine and counted only once the user opts in. The offer's
'data from before you opt in is never sent' sentence names that one exception.
2026-10-06 11:10:43 -07:00
Austin Pickett
950ea4d51e test(desktop): shellSshDouble tolerates a child that exits before reading stdin
The spawn fixtures run commands that never read stdin, so on a fast Linux
runner the shell can exit before shellExec's stdin write lands and the
write fails with EPIPE. The stream had no error listener, so vitest
reported it as an unhandled error and failed the electron project even
though every test passed. The close verdict is the result; the EPIPE is
noise.
2026-10-06 14:07:07 -04:00
xxxigm
db6a9c65e6 test(desktop): prove the relaunch gate survives a transport that refuses big commands
A double that rejects any command over 4 KB - the shape of an OpenSSH mux
socket that cannot pass descriptors behind a large session request - must still
get a verdict, with the judge on stdin and out of argv. Red on base, where the
inlined program makes the gate raise "Could not prove that the remote Hermes
install is clear for SSH startup."

shellSshDouble forwards stdinData the way the real mux exec does, so the live
spawn and v2-marker fixtures now run `python3 -` against a real python3 and keep
proving the argument positions, lock behaviour and verdicts unchanged.
2026-10-06 14:07:07 -04:00
xxxigm
0630e548eb fix(desktop): ship the SSH relaunch marker gate on stdin, not in the ssh command
assertRemoteInstallUpdateClear inlined the ~7.5 KB marker gate program with
`python3 -c`, making the ssh command ~8.1 KB. ssh.exec rides the POSIX
ControlMaster mux socket, where ssh writes the session request and only then
hands our stdio descriptors over with sendmsg(SCM_RIGHTS); a request that still
fills the socket buffer - 8 KB for a unix-stream socket on macOS - leaves no
room for that descriptor message, so the pass fails with EMSGSIZE:

    mm_send_fd: sendmsg(0): Message too long
    mux_client_request_session: send fds failed

ssh exits 255 and the fail-closed gate reads it as "Could not prove that the
remote Hermes install is clear for SSH startup." on every attempt, so no remote
host connects while nothing is wrong on the remote. The window depends on the
client's socket buffer and the command's exact length, which is why a shorter
probe to the same host can still answer.

Hand the program to `python3 -` on stdin instead. Python compiles the whole
program before running it and the gate never reads stdin, so argv keeps the same
`<marker> [payload] [hermes...]` positions while the command stays a few dozen
bytes however the judge grows.
2026-10-06 14:07:07 -04:00
kshitijk4poor
69a12d2203 refactor(desktop,tui): free-tier challenge wiring lives in topical modules, not the facades
- desktop main.ts: the challenge IPC handler moves to
  electron/challenge-window-ipc.ts (registerChallengeWindowIpc), and
  resolvePortalBaseUrl moves next to the portal session that already
  consumed it (portal-session.ts), so the facade ends below its main
  line count; imports are sorted.
- desktop renderer: free_tier.challenge gets its own family handler
  (gateway-event/free-tier.ts) in the dispatcher table instead of a
  branch in handleLifecycleEvent.
- ui-tui: the challenge presenter and the existing billing step-up link
  presenter move to app/gatewayBrowserLinks.ts, keeping the event
  switch at or below its main size; the presenter test moves to its own
  file.

No behaviour change.
2026-10-06 23:14:10 +05:30
kshitijk4poor
8c0568ee73 test(free-tier): drop change-detector challenge tests; keep the invariants
Removed: the desktop/gateway surface-string tests (the gateway's
behaviour is pinned by "never waits", the desktop's by the inherited-flag
invariant), the full-payload-dict snapshot of a status read (the RPC test
covers the field), the server-copy whitespace/length detector, and the
TS "passes optional through as not-required" pass-through check.

Kept: wait outside the locks, background/gateway callers never block,
off-origin URLs refused, no loop on a second 428, the final mint is
authoritative, blips vs verdicts, PoW verdicts unchanged off the token
exchange, control characters never reach a terminal.

Also: FreeTierChallengePayload sorted into events.__all__.
2026-10-06 23:14:10 +05:30
kshitijk4poor
d0461ba6c3 refactor(desktop): free-tier challenge reuses the window-open policy and the contract's outcome type
- 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.
2026-10-06 23:14:10 +05:30
kshitijk4poor
d1f587331e fix(desktop): a required challenge ask never joins or inherits an optional run
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.
2026-10-06 23:14:10 +05:30
Robin Fernandes
90ff655c81 feat(free-tier): browser challenge primitive for the anonymous token exchange
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
2026-10-06 23:14:10 +05:30
Brooklyn Nicholson
cd94de7ea4 test(desktop): tool disclosure cache evicts on unmount
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>
2026-10-06 12:09:55 -05:00
Yuan Li
b8c844b99a fix(desktop): evict inactive tool disclosure atoms 2026-10-06 12:09:55 -05:00
Austin Pickett
fbf261a33f fix(web): preserve repeat count when creating cron jobs from dashboard
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
2026-10-06 12:27:22 -04:00
Austin Pickett
e30c6aecef fix(desktop): a session-picker click re-selects the active session
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.
2026-10-06 12:24:07 -04:00
JoaoMarcos44
70b8d815e6 test(desktop): pin caret rekey semantics 2026-10-06 11:19:00 -05:00
JoaoMarcos44
5beac8a5ae fix(desktop): preserve caret on same-draft scope rekey 2026-10-06 11:19:00 -05:00
Brooklyn Nicholson
02a802a7ef fix(desktop): stream Windows update-marker probe over stdin
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
2026-10-06 11:15:52 -05:00
thegreatesthoneybee
25d7de6fad fix(desktop): preserve Windows SSH probe input encoding 2026-10-06 11:15:52 -05:00
thegreatesthoneybee
5dd73d4641 fix(desktop): stream Windows SSH probe over stdin
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.
2026-10-06 11:15:52 -05:00
ethernet
a0ebd22d57 fix(desktop): stream signing subprocess output 2026-10-06 11:04:35 -04:00
ethernet
39a487e6d7 test(desktop): drop progress-log assertions 2026-10-06 11:04:35 -04:00
ethernet
6b4346fc9c fix(desktop): show after-pack scan and signing progress 2026-10-06 11:04:35 -04:00
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