Commit Graph

49436 Commits

Author SHA1 Message Date
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
Teknium
a285f64133 fix(desktop): update gate and hand-off follow owner liveness (marker v2)
The Electron side of the update-marker contract (C1/C2):

- update-marker: parse v2 (`ct:` creation time, `delegate:` line); an owner
  is live iff its pid is alive, not a zombie, and its creation time matches
  within 2 s (unknown = live). A live v2 owner is never aged out; the
  20-minute ceiling now applies only to v1 markers. The marker is live when
  the owner OR the delegate is live. Every delete is compare-and-delete.
- The bridge marker names Electron's own pid + creation time, claimed by
  exclusive create BEFORE the spawn (it named the cmd.exe wrapper, dead
  within ms). A live foreign owner refuses the hand-off.
- The hand-off counts as started only when the script takes the marker
  within 20 s; a wrapper's exit 0 no longer quits the app. Otherwise the
  Desktop stays up, restarts its backend, shows the error and
  compare-deletes its bridge marker.
- update-gate: a failed receipt no longer outranks a live marker, and a
  live marker owner keeps the boot parked past the old 20-minute deadline.
- handoff-result: parse before unlink, keep malformed files as .corrupt,
  discard a result whose started_at belongs to another run, surface
  ok:true warnings as a non-blocking notice.
- desktop-installation: the repair lock names its holder and a dead
  holder's lock is reclaimed instead of throwing on every launch.

(cherry picked from commit 5d7f36bfaec2213e5c959ad1e0d21d514b9f94b7)
2026-10-06 05:17:45 -07:00
Teknium
55b51fa8ad fix(desktop): attach only to a host backend booted from this checkout
A CLI/launchd `hermes serve` that outlived an update kept serving the old
code; the new Desktop attached on token alone, got 503 "Restart required",
and Restart re-attached the same stale process. `/api/health` now publishes
the boot commit and attach falls to the next ladder rung (next record, then
spawn) unless it equals the checkout HEAD. Unknown Desktop identity
(packaged/non-git) keeps the token-only attach.

(cherry picked from commit e59c7e64bdf417253d3ed9f85710f70adbba3e11)
2026-10-06 05:17:45 -07:00
victor-kyriazakos
5da149b45f fix(bedrock): route GPT-6 family and prefixed OpenAI inference profiles, size their 1M window (#133573)
* fix(bedrock): route GPT-6 family and prefixed OpenAI inference profiles, size their 1M window

An enterprise deployment on provider bedrock with model us.openai.gpt-6.1-sol
compressed at 96,000 tokens with a 400K compression cap configured. Three gaps,
all on origin/main c53536929e:

1. agent/bedrock_adapter.py:77-79 BEDROCK_OPENAI_RESPONSES_MODEL_IDS held the
   GPT-5.5 and GPT-5.6 IDs only (unchanged since 16476fad10). AWS now publishes
   cards for GPT-6 Astra, GPT-6 Sol, GPT-6 Luna and GPT-6.1 Sol.
2. agent/bedrock_adapter.py:165-167 is_openai_bedrock_model was an exact set
   match, so us.openai.gpt-5.6-sol returned False while openai.gpt-5.6-sol
   returned True. Every cross-Region inference profile of a Bedrock OpenAI model
   was misclassified.
3. agent/bedrock_adapter.py:1169-1195 BEDROCK_CONTEXT_LENGTHS had no GPT-6
   rows, so get_bedrock_context_length fell to the 128,000 default silently
   (the fallback WARNING fired only for application-inference-profile ARNs).
   agent/context_compressor.py:1181 applies the 75% small-window floor, which
   is exactly the observed 96,000.

Per the AWS model cards (model-card-openai-gpt-6-1-sol.html and siblings):
the bare openai.* ID is served in-Region by bedrock-mantle (Responses, Chat
Completions, no geo or global IDs), and the us./global. profile IDs are served
by bedrock-runtime where Converse is listed as supported. GPT-6.1 Sol states a
1M context window with 131,072 max output tokens, the other cards state
1,050,000. 272K on these cards is the long-context pricing tier boundary, not
the window.

Changes
- parse_bedrock_openai_model_id(model_id) -> (bare_id, profile_prefix) | None.
  is_openai_bedrock_model is True for every endpoint form of an allowlisted ID,
  GPT-OSS stays False. New bedrock_openai_uses_mantle is True only for the bare
  in-Region ID. Both callers (hermes_cli/runtime_provider_backends.py primary,
  agent/auxiliary_client.py auxiliary) route Mantle on that predicate and keep
  prefixed IDs on Converse via bedrock-runtime, the path AWS documents for them.
  The Claude profile-prefix regex now derives from the same prefix tuple.
- GPT-6 generation added to the allowlist, the static picker catalog and the
  context table. OpenAI rows sized from the cards (6.1 Sol 1,000,000, others
  1,050,000). The table/metadata pairing test covers the new rows.
- get_bedrock_context_length logs one WARNING per model ID per process when
  the static default is used for any ID, naming the model, the value, that it is
  a fallback, and the model.context_length override.
- website/docs/guides/aws-bedrock.md: route table, model table and the
  cross-Region note describe both endpoint forms.

The empty compression summary the deployment also reported is not addressed
here. Converse is a valid path for the prefixed ID, so routing does not explain
it, and no request trace exists yet.

* chore(bedrock): keep auxiliary_client.py within its code-health line cap

Rewrap the _build_bedrock_client docstring back to two lines so the file does not grow past its value on main (code health ratchet, FILE_LINES).

---------

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
2026-10-06 08:13:51 -04:00
Teknium
e97923c38a test(update): the L3 lease test's fake --prepared child writes its result
On main (#132386) a --prepared child that exits without a result file is
a lost completion: _prepare settles it as owed and re-arms the tail for
the next launch. The L3 test's fake child returned 0 and wrote nothing,
so after the rebase the recorded events gained a trailing
arm_completion from that settle path. The fake now behaves like a
successful child and writes its result; the ordering assertion the test
exists for (lease first, then everything that writes the checkout) is
unchanged.
2026-10-06 04:36:02 -07:00
Teknium
00ba600fc0 refactor(cli): container exec routing moves out of main.py into main_container.py
main.py may only shrink vs main (code-health FILE_LINES); this branch grew it
by 9 lines. _probe_container and _exec_in_container (the NixOS container-mode
re-exec, 96 lines) move verbatim to the topical sibling
hermes_cli/main_container.py; the entry point is now public
(exec_in_container) and main() late-imports it at its only call site. The
test import and the managed-runtime resolution allowlist entry follow the
move. No re-export shim. Behaviour is unchanged.
2026-10-06 04:36:02 -07:00
Teknium
b82d35571c test: browser tree-kill unit tests no longer SIGKILL real processes under PID 999
Two mocked tests drive _legacy_kill_process_tree down its shared-group
branch with a MagicMock proc whose pid is 999. That branch snapshots
psutil.Process(proc.pid).children(recursive=True) and SIGKILLs each
descendant, and psutil was never patched: the tests killed the real
descendants of whatever the machine runs as PID 999. On macOS runners
PIDs are allocated system-wide, and when 999 was an ancestor of the
test worker the file died mid-run (CRASHED, killed by SIGKILL), which
is how it surfaced on a PR whose diff never touches the browser tool.

Both tests now run against a fake process table, and the shared-group
test asserts the snapshotted descendant is killed individually (the
contract it was written for).

Live repro: inside an unprivileged user+PID namespace with PID 999 an
ancestor of the test runner, main's file reports CRASHED (killed by
SIGKILL); with this change it passes 9/9.
2026-10-06 04:36:02 -07:00
Teknium
e046826bf3 fix: an unopenable checkout lock reads held to every Python reader, as the scripts and Desktop read it
checkout_lock_held answered "free" when the lock file existed but could not be
opened. The hand-off scripts (marker.sh / marker.ps1) and the Desktop probes
(checkout_held, WINDOWS_CHECKOUT_LOCK_PS) answer "held" for the same file, so a
Python reader could reclaim a marker or admit a launch the others would park.
Only a missing lock file is free now; an existing file that cannot be opened
answers held. A lock call that fails outright (ENOLCK on NFS without lockd,
EOPNOTSUPP on some SMB shares) still answers free: nothing can hold such a
lock, and the interrupted-pull repair relies on that to run unguarded there.
Both callers that act on "held" are bounded (_reclaim_dead keeps the dead
marker; the acquire path waits its contention budget).
2026-10-06 04:36:02 -07:00
Teknium
c36819f063 fix: the F2 replacement-claim regressions run under a psutil-less live-system guard
Both SIGSTOP/SIGCONT reader regressions resume their own stopped reader child with os.kill.
The live-system guard proves a pid is the test's descendant through psutil; in a test
interpreter without psutil it cannot, and it blocked the SIGCONT before the assertion (the
reviewer's F2 re-run hit exactly this). Reproduced by hiding psutil on PYTHONPATH: both tests
raised the guard's RuntimeError on os.kill(<reader>, 18); with the marker both pass. They signal
only fixture-owned children, so they carry live_system_guard_bypass like the custody tests.
2026-10-06 04:36:02 -07:00
Teknium
b7179ad67f fix: the update's Windows child runner and the probe runner share one kill-and-drain
`update_custody.run` (a timed-out Windows update child) re-implemented the tree kill and the
bounded pipe drain of `_subprocess_compat.bounded_probe_run`: its own `taskkill /T /F` copy of
`kill_process_tree`, then `communicate()` bounded, abandoning pipes a missed descendant still
holds. Both now call `kill_and_drain`. It stays correct for the job-bound child: the tree kill
never touches the update's job (which also holds the update's other children), the drained
output still rides on the re-raised TimeoutExpired, and abandoned pipes are detached so the
`with popen()` exit never closes a pipe a reader thread is blocked on. The probe keeps closing
its own private job first.

Tests: test_a_timed_out_child_never_waits_on_a_grandchild_holding_its_pipes and
tests/hermes_cli/test_bounded_probe_run.py (unchanged) pass. (kshitijk4poor review)
2026-10-06 04:36:02 -07:00
Teknium
ab8ef5a850 fix: every Python identity reader judges our own pid by one incarnation rule
`incarnation_live` (holder records, the pause record) and `_identity_live` (the marker
reader, matching marker.rs) restated the same (pid, creation time) rule and disagreed when our
own creation time is unreadable: the marker reader treated a no-ct claim naming our pid as ours
(a degraded updater writes no-ct claims) and a ct one as dead, while `incarnation_live`
answered "unprovable" for both, so `_lock_holder` named a previous incarnation as the holder.
Both now go through `_incarnation`; `_identity_live` only adds the v1 age ceiling for an
unprovable identity. `_compare_and_delete` had no callers and is removed.

Test: test_one_incarnation_rule_when_our_own_creation_time_is_unreadable fails on the previous
head and passes here; the shared marker corpus is unchanged and green. (kshitijk4poor review)
2026-10-06 04:36:02 -07:00
Teknium
61470f10a4 fix: a Ctrl-C'd build runner can no longer kill the custodian of its detached writers
`run_contained` starts the POSIX build launcher with `subprocess.run`, which SIGKILLs its own
child when KeyboardInterrupt escapes the wait. That child was the custodian holding the
checkout lock fd: moving it out of the caller's group (E) survives a group kill, not a direct
kill. A command still cleaning up from the same Ctrl-C then exited, dropped the last lock fd,
and a writer in a session of its own went on writing under the next lock owner (review R4).

The process the runner sees is now a stand-in: it forks the custodian into a group of its own,
relays SIGINT/SIGTERM/SIGHUP to it and returns its exit status. A killed stand-in leaves the
custodian to settle the tree: node gets the same 10 s it gets after a forwarded signal, then
every descendant is killed and reaped, and only then is the fd dropped.

Test: test_a_ctrl_c_keeps_custody_until_a_detached_writer_is_gone (verbose + captured runner,
slow and instant SIGINT cleanup; real run_in_custody, real SIGINT to the caller's group, real
flock contender) fails on bc9311b904c and passes here.
2026-10-06 04:36:02 -07:00
Teknium
1f32d73ed8 fix: Desktop SSH marker readers judge the v2 marker with update_lock's one parser and identity rule
The POSIX relaunch gate (REMOTE_UPDATE_MARKER_PROBE), the managed-update
observer (marker_state) and the spawn-time recheck (marker_clear) each carried
their own marker parser, narrower than update_lock._parse_marker: a BOM, a
space-padded or zero-padded pid, an out-of-range first delegate line before a
valid one, or started_at past 2^53 read as malformed (UNCERTAIN), and liveness
was PID (+argv text) only, not the (pid, creation time) identity Python checks.
The spawn recheck read line 1 only, so a dead owner with a live delegate let a
serve start beside the delegate.

All three now embed one program, REMOTE_MARKER_JUDGE_PY
(remote-update-marker-programs.ts, byte-identical to the judge #132345 ships),
and vitest replays every tests/fixtures/update_marker_corpus.json judge case
through it, so the corpus covers the Electron remote readers as the parser
docstring claims. Fixtures that wrote a live pid with started_at 0/1 now write
the current time: under the shared v1 rule such a claim is past the 20-minute
ceiling and dead, exactly as Python judges it.
2026-10-06 04:36:02 -07:00
Teknium
927990dbb2 test: v2-marker delegate fixtures record the delegate's real creation time
A delegate is live only when its pid AND creation time match (the updater's identity rule,
which #132345's shared judge enforces), so a made-up `ct:1700000001.500` on a live process is
the reused-pid case and correctly reads dead. The fixtures now record the spawn time of a real
delegate (inside the 2 s tolerance), so they hold under both the two-line-era readers and the
judge that replaces them.
2026-10-06 04:36:02 -07:00
Teknium
62c29c2a94 test: R2's custody test stalls git with a clean filter, not a hook the updater no longer runs
The F1 fix runs every updater git mutation with core.hooksPath=<null device>, so the
pre-commit hook this test used to stall `_git_run`'s git never ran and the owner exited
before it could be killed mid-write. R2's invariant is about git and whatever it runs, not
hooks: a clean filter on `git add` (a local mutator) stalls git the same way and inherits the
same lock fd. Still red when `add` does not receive the fd ("a contender owns the checkout
while git still writes").
2026-10-06 04:36:02 -07:00
Teknium
b0cead1d48 fix: a historical takeover reaches its child on a cp1252 Windows pipe
The old updater printed "→ Handing off ..." before spawning the takeover
child. On Windows, a piped stdout encodes in the ANSI code page, so the
arrow raised UnicodeEncodeError (a ValueError) before the child started:
stop_for_relaunch reported "Update takeover failed" and exited 1 for a
committed update. The banner and the Windows job-refusal notice are now
ASCII.

The takeover status test now also runs with strict cp1252 stdio (review S1).
2026-10-06 04:36:02 -07:00
Teknium
20d8a12239 fix: a group kill of the build's caller no longer kills the custodian of its detached writers
The POSIX build launcher (_REAP_TREE) holds the checkout-lock fd for node's tree because node
marks the donated fd close-on-exec. It stayed in the caller's process group, so the
completion child's interruption path (`os.killpg(group, SIGKILL)`) killed it along with node,
while a lifecycle descendant that started its own session survived without custody: a
contender acquired the checkout while that writer kept writing (review E).

The launcher now leaves the caller's group and starts node back in it (`process_group=`):
every group kill still reaches node and what it starts in that group, but the custodian
outlives it, kills and reaps the rest of the tree (subreaper / recorded tree, as on a normal
return) and only then exits and releases the fd. A launcher that already leads its group
(a caller gave it a session) cannot leave it and keeps the old topology.
2026-10-06 04:36:02 -07:00
Teknium
d02352efce fix: a background git hook never keeps a completed update's checkout locked
Local mutators inherit the checkout-lock fd (POSIX) so an interrupted writer keeps custody,
but git hands that fd on to repository hooks. A post-merge hook that backgrounds a process
kept the flock after the merge returned and the owner released, so every later update and
recovery was refused until the daemon exited (review F1, helix4u's report).

The custody config for local mutators now carries `-c core.hooksPath=<os.devnull>` next to
the fsmonitor/credential suppression: internal updater mutations never run repository hooks.
Per command only; the repository's own hook configuration is untouched. Genuine unfinished
mutators still hold the fd (test_lock_fd_reaches_local_mutators_only stays green).
2026-10-06 04:36:02 -07:00
Teknium
49fc76fe92 fix: Desktop SSH readers clear a dead v2 update claim instead of stranding it
The updater now writes a creation-time line 3 (and the hand-off scripts
append delegate:/run: lines), but the client-supplied remote probes still
accepted exactly "<pid>\n<started_at>\n". The SSH relaunch gate answered
UNCERTAIN for an exited writer's v2 claim, kept it, and refused every
reconnect/spawn until some other reader cleared it; the managed-SSH observer
reported the same claim as malformed.

Both POSIX programs now parse the A2 shape: line 1 pid, line 2 started_at,
line 3 ignored (ct or blank, as the Python reader treats a bad line 3), and
the first well-formed delegate:<pid> ct:<ct> as a second holder. The claim is
dead only when the owner AND the delegate are dead, so dead-holder detection
is never weaker than v1. The relaunch gate's delete now runs under the
updaters' <marker>.lock mutex (non-blocking; held -> UNCERTAIN) and only while
the bytes still equal the ones judged dead, so a claim published after the
read is never removed.

Review F3 (PR #132365 thread 3).
2026-10-06 04:36:02 -07:00
Teknium
45a5d646c4 fix: a reader reports the live claim that replaced its stale marker snapshot
read_live_update() read a dead marker, then rechecked it under the marker
mutex in _reclaim_dead(). When a marker-only claimant (the launch hand-off,
no checkout lease) published in between, the recheck correctly kept the
replacement and returned "live" -- but the reader only handled "held" and
fell through to None. update_in_progress() then answered False for a live
update, and the skew watchdog counted that poll toward retirement; the
checkout-lock OR cannot correct it because a marker-only claim holds no
checkout lease.

A "live" recheck verdict now re-judges the marker once and returns its holder.

Review F2 (PR #132365 thread 2).
2026-10-06 04:36:02 -07:00
Teknium
0abe27908b fix(update): the watched fetch's timeout drains its pipes for a bounded time
Follow-up to the R3 watchdog (review R3, kshitijk4poor 5411135165): after killing a timed-out
fetch, its output is drained for at most _DRAIN_SECONDS, as update_custody.run does on Windows,
so a git-remote-https helper still holding the pipes never hangs the update (health HX006). The
watchdog's SIGKILL line is marked POSIX-only for the Windows footgun scan: run_git never takes
that path on win32.
2026-10-06 04:36:02 -07:00
Teknium
97ca549f6d fix(update): an unwritable marker or checkout lock says why the update needs it and how to fix it
Review P2 (kshitijk4poor 5411135165): an unwritable marker directory/marker or checkout lock
now refuses the update where main ran it unlocked, and the refusal said only "not writable".

Decision: the refusal stays. Before the commit point it changes nothing (the update has not
started), and the marker is what the Desktop gate, the SSH remote probes, the Tauri updater and
the hand-off scripts read; running without it re-opens the race the lock exists to close.
nix/docker installs are stopped earlier by _update_preflight_handled. The checkout lock lives in
the git directory, which the update's own git commands must write anyway, so refusing there up
front only replaces a later git failure.

What changed is the message: each reason names what the path is for (the marker: the Desktop
app, gateways, installers and other updaters wait on it; the checkout lock: the git directory
git must write too), and describe_holder's remedy covers a read-only filesystem (remount
read-write, or fix owner/permissions) besides running as the owning user.

Test: an unwritable marker location's message names the path, its readers and the read-only
filesystem remedy; RED on the start sha.
2026-10-06 04:36:02 -07:00
Teknium
11481c55bb fix(update): a macOS fetch dies with its killed update owner (review R3)
The updater's fetch runs without the checkout lock fd (m3: its network half and any
credential daemon must never hold the checkout). On Linux PR_SET_PDEATHSIG ends it when the
owner dies; macOS has no parent-death signal, so a killed `hermes update` left the fetch
rewriting refs (and holding live `*.lock` ref files) under the next lock owner. The docstring
pointed at a NOT_COVERED note that does not exist.

Where no parent-death signal can be armed, run_git now runs an fd-less ref writer (`fetch`)
under a stdlib watchdog: its stdin is a pipe only the owner writes; a byte means the fetch is
done, EOF without one means the owner died, and it SIGKILLs the fetch, as the death signal
would. It holds no lock fd and runs in its own session. A watchdog that cannot start is logged
and the fetch runs as before. Readers and ls-remote write nothing and stay as they were;
promisor lazy fetches add content-addressed objects only and are not watched.

Test: the existing killed-owner fetch cell, run with the parent-death signal disabled (the
macOS condition); RED on the start sha ("git fetch outlived its killed update owner").
Review: kshitijk4poor 5411135165, open thread R3 (partly fixed).
2026-10-06 04:36:02 -07:00
Teknium
9a21cc168d fix(update): the partial-clone pack tidy's merge runs in the update's custody
What: git_pack_tidy's `pack-objects --stdin-packs` merge (main a0f47657b9) now goes through
update_custody.run_git, and `pack-objects` is a LOCAL_MUTATOR: it holds the checkout lock fd, and
inside an update on Windows it is job-bound. A timeout or spawn failure still reads as out of time;
a custody refusal propagates like every other updater git's. bounded_probe_run loses the
`popen_kwargs` parameter this branch added for the gc fold, which #132927 removed; nothing passes it.
Why: the tidy replaced the gc fold this branch had put in custody, and arrived as a bare git
spawned during `hermes update` under the update lock. It writes a pack the update then moves into
the object store; outside custody a killed update could leave it writing into
hermes-tidy-staging after the lock is free, and on Windows it was outside the update's job.
The tidy only runs under the lock (main.py acquires UpdateLock before _cmd_update_impl), so on
Windows it always takes custody's bounded drain, not the bare subprocess.run timeout cleanup
bounded_probe_run exists to avoid (#68609).
Red: test_merges_down_to_the_target_into_one_pack_whatever_the_size_limit saw custody spawn only
[('config', False), ('config', False)]. Green: pack tidy, custody, bounded probe, build stamp and
no-window-flag suites, 45 passed.
2026-10-06 04:36:02 -07:00
Teknium
64be53f9b5 fix(update): the old updater's POSIX takeover run carries the same unbounded-child waiver as Windows
What: _old_updater._run_in_custody's POSIX `subprocess.run(argv, ...)` gets the HX006 waiver and
the reason the Windows `proc.wait()` already had: the takeover is the rest of a committed update
(sync + build), and it holds the checkout lock fd, so the checkout stays locked while it runs.

Why: both branches wait unbounded by design (a timeout would abandon a committed update mid-write),
but only the Windows one said so; the POSIX twin read as an unreviewed missing timeout (review L7).
Comment-only: no behaviour change, so no RED test (old-updater suites pass: 699).
2026-10-06 04:36:02 -07:00
Teknium
3004b252c9 fix(update): read_live_update's fail-open waiver names the checkout kernel lock as the guard
What: the BLE001 waiver and docstring of read_live_update now say what makes the never-raises
probe safe: an unjudgeable marker fails open on the MARKER only; every caller that acts on None
still consults the checkout kernel lock (update_in_progress ORs checkout_lock_held,
UpdateLock.acquire/acquire_checkout take it). Behaviour unchanged.

Why: the old waiver ("unjudgeable = no live update") read as a fail-open where main caught only
OSError/ValueError (review L5). Narrowing the catch was rejected: the judge also runs the
incarnation probes (psutil/ctypes/proc readers), so a narrow catch would let an unexpected error
escape into update_in_progress and the launch path, which rely on never-raises; the guard that
actually prevents a race is the kernel lock, now named and pinned.

Test: an unjudgeable marker (the parse raising an unexpected exception) over a checkout lock held
by another process -> read_live_update None, update_in_progress True, a contender's acquire
refused. Comment-only fix, so this pins the guard (GREEN on bbfa23b137d too), it is not a RED test.
2026-10-06 04:36:02 -07:00
Teknium
aff4a85d3b fix(update): the Windows bootstrap completion child leases the checkout before it writes it
What: update_completion._prepare on win32 joins the checkout lock first (update_lock._acquire_checkout,
the joiner path that takes an R5b lease byte, exactly what the --prepared child does through
complete_source_checkout's UpdateLock). It holds that lease for its lifetime (through the --prepared
child); a refused join raises before arm_completion, so main() fails the preparation boundary.

Why: a bootstrap child whose job bind was refused runs outside the kill-on-close job and outlives a
killed owner, yet it ran arm_completion, pm.sync_venv (uv children) and
collect_superseded_generations holding no lease; the lease was only taken later in the --prepared
grandchild, so the checkout could be handed to a contender while the sync still wrote (review L3).

Tests (new file, RED on bbfa23b137d, GREEN here; Windows faked for update_completion only): the
lease is the first event, before arm_completion/sync/collector/--prepared; a refused join writes
nothing.
2026-10-06 04:36:02 -07:00
Teknium
ab8a00d2be fix(update): the Node build stays in the caller's process group and leaves no descendant behind
What: the POSIX build launcher (_OWN_GROUP -> _REAP_TREE) no longer starts node in a session of
its own. Node stays in the caller's process group; the launcher holds the checkout lock fd and,
once node exits, kills every remaining descendant: on Linux it is a child subreaper
(PR_SET_CHILD_SUBREAPER) and SIGKILLs + reaps its own children until none is left; elsewhere
(macOS) it records node's descendant tree from ps while node runs and kills the recorded
processes whose start time still matches. SIGINT/SIGTERM/SIGHUP are forwarded to node (killed
10 s later if still running), then the descendants are killed the same way.

Why: start_new_session=True moved node out of the caller's group, so every existing group kill
(update_completion's killpg on Ctrl-C, Desktop's kill(-pid), killHandoffTree) reached only the
launcher; node was orphaned and its npm/esbuild descendants kept writing after the checkout lock
was free (review L1, probe review/lock/custody/p1: grandchild wrote after abort, lock free).
The old killpg after p.wait() also signalled a reaped leader's group (pid-reuse window, review
L6): the launcher now signals only its own unreaped children (Linux) or start-time-verified
pids (ps walk), never a group.

Tests (RED on bbfa23b137d, GREEN here): a group kill of the caller-visible group stops node and
its child; a detached (setsid) grandchild outliving node is killed before the build returns, on
the subreaper and the ps-walk paths; SIGTERM to the launcher exits 143 and leaves no writer.
2026-10-06 04:36:02 -07:00
Teknium
8de219355f test(update): the F54 control job sets its breakaway flag through the extended limits
JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK is only accepted through
JOBOBJECT_EXTENDED_LIMIT_INFORMATION (class 9); class 2 rejects it, so the Windows lane
failed building the control job before reaching the custody launcher. Same shape as
process_identity's job setup; the assertion now carries GetLastError.
2026-10-06 04:36:02 -07:00
Teknium
1c8576a10c test(update): fake win32 for update_custody only, not the whole process
Patching update_custody.sys.platform patches the sys module itself, so any lazy
import inside the timed run took its Windows branch: the suite's Popen guard imports
gateway.status there, which then imports msvcrt (red on macOS when that import came
first). A sys proxy scoped to update_custody drives the same Windows branch of run().
2026-10-06 04:36:02 -07:00
Teknium
1199506a80 test(update): the borrowed-home tail fixture patches the custody spawn
The launch-time completion tail now spawns through update_custody.run (checkout
custody), but this file's fixture still patched venv_sync.subprocess.call: the owner
test ran a real source_completion.py child against a scratch checkout (red on macOS)
and the borrowing test's 'no tail ran' assertion was vacuous. Patch the seam
production uses, as test_update_launch_completion.py already does.
2026-10-06 04:36:02 -07:00
Teknium
39961e89ce chore(update): waive the deliberate catch-alls and unbounded update children for the health ratchet
The six findings are deliberate boundaries, so each gets an explicit
`# health: allow` with its reason instead of a narrowed catch:
- _old_updater._run_in_custody / update_completion._prepare (HX006): the
  waited child is the rest of the committed update (sync + build); it is
  supervised and dies with the updater, a timeout would abandon it mid-write.
- _old_updater._checkout_custody (BLE001): calls whatever older release's
  update_lock is loaded; no fds means the takeover takes the lock itself.
- update_custody._past_commit (BLE001): fail-closed, unknown = past commit.
- update_custody._held (BLE001): a torn tree's half-written module can raise
  anything at import.
- update_lock.process_create_time (BLE001): liveness probe; any psutil failure
  (including partial/stub modules) degrades to the stdlib probe instead of
  escaping to read_live_update.
- update_lock.read_live_update (BLE001): documented never-raises probe.

Existing `# noqa: BLE001` comments become the health waiver (ruff does not
select BLE here). Comments only; behaviour is unchanged.
2026-10-06 04:36:02 -07:00
Teknium
901950925a test(update): new lock tests write/read bytes (Windows footgun gate)
The tests added for N06/F12/PID-reuse/readonly-lock used bare write_text/read_text('utf-8'); check-windows-footguns flags those. Bytes I/O, same assertions.
2026-10-06 04:36:02 -07:00
Teknium
51044e75e3 fix(update): the Rust installer honours HERMES_DATA_DIR_SUFFIX for the default home and marker root (review F13)
What: platform_default_home() appends HERMES_DATA_DIR_SUFFIX literally to the hermes/.hermes leaf via platform_default_home_with(suffix), which fixes hermes_home(), hermes_root() and the update marker together.
Why: Python (_get_platform_default_hermes_home) and Desktop (platformDefaultHermesHome) both append it, so a hermes-setup launched in a suffix-only channel environment read and wrote the unsuffixed home and marker.
Red: paths::tests::data_dir_suffix_names_the_default_home_and_its_marker_root on the 0ec53574011 resolution (byte-verbatim extract, suffix ignored): left '.hermes' right '.hermes-channel-build-x'.
Green: passes on the real fixed source extract; also cargo check clean for x86_64-pc-windows-msvc (Tauri crate itself cannot build on this host).
2026-10-06 04:36:02 -07:00
Teknium
fc7d24650d fix(update): an exited Windows owner with exit code 259 is dead, not running forever (review F10)
What: the Windows pid_is_alive opens the process with SYNCHRONIZE and returns WaitForSingleObject(h, 0) == WAIT_TIMEOUT; access denied still reads alive via liveness_from_open_error.
Why: GetExitCodeProcess == STILL_ACTIVE (259) is also what a process that exited with code 259 reports while any handle keeps its object, and its creation time still matches the v2 marker, so Busy had no age bound. Python's _early_recovery already uses the wait probe.
Red: Windows-only; marker_tests::pid_is_alive_false_for_an_exited_process_whose_exit_code_is_259 (cfg(windows)) needs a native runner.
Green: cargo check --tests --target x86_64-pc-windows-msvc clean on the real marker.rs (std-only harness); Linux marker tests 49 passed.
2026-10-06 04:36:02 -07:00
Teknium
e0cd07ee43 fix(update): tmp litter under our own reused pid number is reclaimed once stale (review PID-reuse-tmp-sweep)
What: the Python and Rust tmp sweeps reclaim a tmp sibling named for our own pid once its mtime is older than 60 s (OWN_PID_TMP_STALE[_SECONDS]); other pids keep the liveness check, and an ASCII pid past u32 is dead in both (Python had kept it since N06; Rust always swept it).
Why: with deterministic container pids a previous holder of our pid number left a tmp that every later run skipped as 'our own in-flight write', so it was never cleaned. Every write of ours finishes far inside 60 s, so a fresh same-pid tmp is still kept.
Red: test_stale_tmp_under_our_own_pid_number_is_reclaimed FAILED and marker_tests::stale_tmp_under_our_own_pid_number_is_swept FAILED on 07c014355b5 (real marker.rs via a std-only harness; the Tauri crate cannot build here without webkit2gtk/dbus).
Green: both pass; test_update_lock.py 59 passed/1 skipped; marker tests 49 passed/2 ignored.
2026-10-06 04:36:02 -07:00