Skip to content

fix(update): honesty train — HEAD-move gate, surfaced restart aborts, verified cold-start, self-lock defence - #86687

Merged
teknium1 merged 9 commits into
mainfrom
salv-update-train
Aug 15, 2026
Merged

fix(update): honesty train — HEAD-move gate, surfaced restart aborts, verified cold-start, self-lock defence#86687
teknium1 merged 9 commits into
mainfrom
salv-update-train

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

What does this PR do?

Consolidated hermes update honesty train for the Windows/macOS install-update bug class — four fixes that share hermes_cli/update_cmd.py / main.py, sequenced to compose cleanly (each later commit was retested against the earlier ones):

  1. thatssoheil (PR fix(update): gate 'Code updated!' on HEAD actually moving (#79678) #79734, fixes hermes update re-detaches HEAD after a successful pull, silently discarding the update #79678): gate ✓ Code updated! on HEAD actually moving. A detached/pinned checkout can pass merge --ff-only yet stay on the old SHA; the update then reinstalled deps and rebuilt the desktop against the stale tree while claiming success. Now it compares pre/post-pull SHAs and fails loudly with the reattach command.
  2. PRATHAMESH75 (PR fix(install): surface aborted gateway restart during hermes update (#78574) #78590, fixes [Bug]: Linux default gateway can stay stale after hermes update, causing ImportError #78574, 2 commits): surface an aborted gateway auto-restart phase. The whole phase was wrapped in except Exception: logger.debug(...), so an early failure silently left gateways serving pre-update modules while the update printed "Update complete!" and exited 0. Now: pre-restart PID snapshot + survivor probe + pure fail-closed decision helper ([] after a stopped gateway is NOT proof of safety), recovery warning, .update_exit_code 1 in gateway mode, exit 1. Includes egilewski's empty-survivor fail-open fix; monerostar verified on native Win11 (10 tests) and Linux.
  3. chelsealong (PR fix(update): verify Windows gateway cold-start survives before reporting success #84212, addresses [Bug]: Windows: gateway cold-started after 'hermes update' dies silently (no logs, no PID file, no exit record) — offline until manual restart #84185): don't print ✓ Starting Windows gateway after update (PID n) straight off a successful Popen — a job object denying breakaway kills the child before it logs anything (Halldrix's harness proved CREATE_BREAKAWAY_FROM_JOB can be silently ignored). The cold-start success line is now gated on the same _report_gateway_start post-spawn liveness poll every other _spawn_detached caller uses. This closes the false-success reporting half of [Bug]: Windows: gateway cold-started after 'hermes update' dies silently (no logs, no PID file, no exit record) — offline until manual restart #84185; the deeper schtasks-based spawn escape discussed in that thread is follow-up scope.
  4. Halldrix (PR fix(update): detect updater self-lock on Windows + repair uv-managed venvs #83590, fixes Windows: hermes update self-locks cryptography._rust.pyd — the updater process itself holds the .pyd mapped; any cryptography bump fails with os error 5 (no gateway/desktop required) #83569, 2 commits): Windows updater self-lock defence — refuse the dependency sync when the updater process itself has a native venv extension mapped (cryptography._rust.pyd), write .update-incomplete, and complete the pending core install in _early_recovery before any native import on the next launch (new stdlib-only hermes_cli/_install_repair.py shared by early and late recovery paths; attempts-counter backoff; PEP 668/uv-managed base interpreter repair via uv pip / --break-system-packages). Addresses egilewski's circular-handoff review with the pre-import completion regression.

Follow-up commits by me: contributor email mapping, plus test seam updates — with the restart phase now surfaced (fix 2), tests that drive cmd_update end-to-end must mock gateway discovery or they reach the conftest live-system guard on a box with a live gateway (autouse fixture in test_cmd_update.py, same seams in test_update_head_moved_gate.py).

Related Issues

Fixes #79678, #78574, #83569. Addresses the reporting half of #84185. Supersedes #79734, #78590, #84212, #83590 (all cherry-picked here with authorship preserved).

Verification (Linux)

  • scripts/run_tests.sh over the 10 touched/adjacent test files: test_cmd_update.py, test_update_autostash.py, test_lazy_refresh_venv_repair.py, test_update_concurrent_quarantine.py, test_update_head_moved_gate.py, test_update_gateway_restart_aborted.py, test_update_cold_start_gateway_liveness.py, test_update_self_lock.py, test_early_recovery.py, test_update_interrupted_recovery.py87 passed, 0 failed.
  • python3 scripts/audit_pr_attribution.py — all contributor emails mapped.

Needs Windows smoke (not executable here): live hermes update on a Windows host with a running gateway (job-object teardown path) and a cryptography version bump (self-lock path). The Linux-testable logic (marker lifecycle, decision helpers, liveness-gated reporting, uv/PEP 668 branches) is covered by the suites above.

Infographic

update-honest

thatssoheil and others added 8 commits August 14, 2026 20:54
A detached/pinned checkout can report 'N new commit(s)' against origin,
run the ff-only merge successfully, and still sit on the old commit
afterward (the branch-switch step re-detaches to the raw SHA). Before
this guard 'hermes update' printed '✓ Code updated!' and reinstalled
deps + rebuilt the desktop app against the stale tree - no error, no
warning, 'hermes doctor' healthy.

Compare pre-pull and post-pull HEAD; if they match, fail loudly with a
reattach hint instead of claiming success.
The gateway auto-restart phase in `hermes update` was wrapped in a blanket
`except Exception` that only logged at debug level. When the phase raised
early — e.g. importing `hermes_cli.gateway` from the freshly pulled checkout
inside a process that already loaded pre-update modules — every drain and
restart line vanished from the update output, the update printed
"Update complete!" and exited 0, and the still-running gateway kept serving
pre-update modules against replaced source files. The next Telegram turn died
with `ImportError: cannot import name 'is_trivial_prompt'`.

The handler now probes for surviving gateway processes and, unless it can
positively prove none are running, prints the cause plus a manual recovery
command and marks the fleet restart incomplete — which exits nonzero and
writes the gateway-mode exit-code marker, matching the existing
failed-or-stale-unit path.

Fixes #78574
…ivor probe

Review follow-up (#78574): the aborted-restart handler only flagged the fleet
stale when the post-failure survivor probe was None or non-empty. A positive
empty probe was treated as proof-of-safety — but `[]` is only safe when
nothing was running before the phase. If a gateway was discovered, stopped
(SIGTERM/drain), and its replacement never came back, the probe is empty at
exactly that unsafe moment and the update reported success — the fail-open
contract this fix exists to close.

Snapshot the pre-restart gateway PIDs before any stop/drain and route the
handler decision through a pure _restart_phase_failure_is_incomplete() helper
that fails closed on an empty survivor set whenever a gateway existed
pre-restart (or the pre-state could not be read). Add decision-level regression
tests covering the stopped-without-replacement gap, unknown pre-state, and the
truly-no-gateway positive control.
…ing success

_cold_start_windows_gateway_after_update() printed the success line off a
successful Popen return alone, which only proves CreateProcess succeeded,
not that the child survived. On Windows, a job object denying
CREATE_BREAKAWAY_FROM_JOB hard-kills the child during updater teardown
before it logs anything, yet the updater still printed "Starting Windows
gateway after update (PID ...)" — leaving Telegram/Discord/etc. offline
with no indication anything failed (#84185).

Route the success report through gateway_windows._report_gateway_start(),
the same post-spawn liveness poll every other _spawn_detached() caller
already uses, so a dead child is reported as a failure with a
manual-recovery hint instead of a false success.
… base interpreter is uv-managed

Two gaps left every Windows git-checkout install unable to recover from
the exact failure state #83569 reports:

1. Self-lock detection. _detect_venv_python_processes() always excludes
   the calling process by design — a CLI hermes update IS the venv python.
   An updater that had already imported a native venv extension (the
   canonical one being cryptography.hazmat.bindings._rust, mapped while
   hermes_cli.main resolved external secret sources) passed every
   preflight and then died mid-sync with os error 5 when uv tried to
   rewrite the mapped .pyd, stranding the venv half-updated. A new
   preflight now refuses the sync before touching the checkout, writes
   the update-incomplete marker so the next fresh launch completes the
   install, and exits 2. Verified on a live Windows 11 host: after
   importing hermes_cli.main, tasklist /m _rust.pyd shows the .pyd mapped
   in the caller, and a peer process cannot open it read-write
   (Permission denied) — while a rename succeeds, matching how uv/pip
   actually fail (truncate+write, not rename).

2. Early-recovery install path. _early_recovery._run_repair_install used
   sys.executable -m pip unconditionally. Windows git checkouts install
   on a uv-managed base interpreter (python-build-standalone), whose
   EXTERNALLY-MANAGED marker makes plain pip abort with
   externally-managed-environment — the repair no-oped and the venv
   stayed broken. The repair now detects the PEP 668 marker, prefers
   uv pip install with VIRTUAL_ENV pointed at the project venv, and
   falls back to pip --break-system-packages when no uv binary exists.

Both fixes ship with subprocess/unit regressions (sabotage-verified):
the new tests fail on pre-fix code and pass with it. Complements #77517,
which keeps the updater from importing cryptography in the first place;
this PR is the defence-in-depth when any future path loads it anyway.

Fixes #83569
…self-lock loop fix)

Reviewer egilewski found the original defer was circular (#83590 comment):
the self-lock preflight wrote .update-incomplete and exited, but the next
launch only ran the full recovery AFTER main.py's third-party imports —
so a healthy venv's probes made the early pass a no-op, main.py imported
cryptography eagerly, the .pyd got mapped again, and the deferred install
re-hit the exact self-lock it was meant to escape.

Close the loop by making the marker guarantee the install runs BEFORE any
native extension can be imported:

- hermes_cli/_install_repair.py (new, stdlib-only): single source of truth
  for the core .[all] reinstall — ensurepip bootstrap, uv-pip/pip
  resolution with VIRTUAL_ENV, Termux env stripping, Windows hermes*.exe
  quarantine, per-extra fallback ladder, and fd1→fd2 routing for acp
  safety.  Deliberately free of managed_uv/hermes_constants imports so it
  stays importable in the corrupted-venv state it exists to repair.
- hermes_cli/_early_recovery.py: recover_if_needed now completes a pending
  .update-incomplete install BEFORE the import probes, on every launch
  that sees the marker (unless argv is update).  Success clears the
  marker; failure bumps an attempts counter inside the marker body and
  keeps it.  A 3-attempt ceiling stops a persistently-failing install
  from reinstall-hammering every launch (hermes acp included) — past the
  ceiling the late post-import recovery takes over with its manual
  recovery instructions.  Single-flight lock shared with the late path.
- hermes_cli/main.py: _recover_core_update_marker_locked delegates the
  install to the shared executor (no duplicated logic); ensure_uv stays
  in the late path so a venv whose uv vanished mid-update still
  bootstraps it.
- tests: 7 new regressions — the reviewer's exact case (marker + healthy
  venv → install runs while sys.modules has no cryptography), failure
  keeps marker + increments attempts, retry ceiling, lazy marker does
  not trigger core install (#58004 invariant), argv-update skip, and
  corrupt/missing marker bodies.  The key test was sabotage-verified:
  removing the pre-import branch makes it fail with zero install calls,
  while a lone-lazy-marker test still passes; restoring the branch makes
  it pass again.

Refs #83569
…rfaced

The gateway auto-restart phase used to swallow every exception at debug
level, so tests driving cmd_update end-to-end never noticed it touching
real gateway discovery. With #78574 surfacing an aborted restart as a
failed update, an unmocked find_gateway_pids on a box with a live
gateway hits the conftest live-system guard and turns into a spurious
sys.exit(1).

Add an autouse fixture in test_cmd_update.py (discovery returns nothing,
systemd unsupported) and the same seams in test_update_head_moved_gate's
helper so the phase is a clean no-op for tests that do not assert on
gateway restarts.
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cli CLI entry point, hermes_cli/, setup wizard area/install-update Installer, updater, packaging, wheels, doctor platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 7a42bcb — fix(update): use canonical venv_bin_dir in _install_repair (

⚠️ Warnings

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 11m26s vs 20m57s (-45.4%). 10 job(s) slower, 11 faster, 3 unchanged.

  • Python tests / Run tests slice 8/12: +32.0s
  • Python tests / Run tests slice 1/12: -21.0s
  • Python tests / Run tests slice 4/12: -20.0s
  • Python tests / Run tests slice 6/12: -19.0s
  • Python tests / Run tests slice 5/12: -18.0s

@teknium1
teknium1 merged commit 42a1db4 into main Aug 15, 2026
45 checks passed
@teknium1
teknium1 deleted the salv-update-train branch August 15, 2026 05:03
Halldrix added a commit to Halldrix/hermes-agent that referenced this pull request Aug 15, 2026
…-lock on Windows

The secrets_cli import in main() was eager, which loaded
agent.secret_sources.bitwarden and its cryptography.* dependencies
before cmd_update() ran. On Windows, the updater process itself
then mapped cryptography._rust.pyd into its own address space,
triggering the self-lock detector (_detect_self_loaded_native_modules)
and causing a defer/exit-2 loop that blocked updates entirely.

Move the secrets_cli import inside the _dispatch_secrets function so
it only pays for itself when the user actually runs a secrets
subcommand. This keeps hermes update (and all other commands) free
of the cryptography._rust.pyd eager load.

Refs NousResearch#83569, NousResearch#83590, NousResearch#86687

Test: 3 new regression tests verify cryptography._rust stays out of
sys.modules during main() and the update path.
teknium1 pushed a commit that referenced this pull request Aug 15, 2026
…-lock on Windows

The secrets_cli import in main() was eager, which loaded
agent.secret_sources.bitwarden and its cryptography.* dependencies
before cmd_update() ran. On Windows, the updater process itself
then mapped cryptography._rust.pyd into its own address space,
triggering the self-lock detector (_detect_self_loaded_native_modules)
and causing a defer/exit-2 loop that blocked updates entirely.

Move the secrets_cli import inside the _dispatch_secrets function so
it only pays for itself when the user actually runs a secrets
subcommand. This keeps hermes update (and all other commands) free
of the cryptography._rust.pyd eager load.

Refs #83569, #83590, #86687

Test: 3 new regression tests verify cryptography._rust stays out of
sys.modules during main() and the update path.
teknium1 added a commit that referenced this pull request Aug 15, 2026
…swap is at risk (#86735, #86780, #86781)

The #86687 self-lock preflight fired on every Windows `hermes update`:
bitwarden.py's module-level cryptography import (fixed in #86782 /
#86826-class change) meant cryptography._rust was ALWAYS mapped by the
time the preflight ran, so the update exited 2 before even fetching and
looped forever — including the Desktop in-app update (#86780).

Two structural fixes so the guard can never re-brick the flow it protects:

1. Version-gated detection: _detect_self_loaded_native_modules() now
   consults _dependency_sync_would_rewrite(dist) — installed version vs
   the on-disk pyproject pins (base deps + all extras, env markers
   honored). A loaded module whose distribution the sync will not touch
   is no lock risk and is not reported. Unknown → fail closed.

2. Relocated deferral: the check no longer runs pre-fetch. It runs via
   _abort_dependency_sync_if_self_locked() immediately before each venv
   rewrite (git-path dep sync, ZIP-path dep sync, current-checkout venv
   repair) — AFTER the code swap. A deferral now leaves the user on NEW
   code with only the dependency install pending (completed by the next
   launch's marker recovery), instead of stranding them on the old
   checkout in an exit-2 loop.

PyYAML's _yaml extension (loaded by every CLI process) joins the
registry — with version gating it is now safe to list.

Tests: version-gate unit coverage (no-change skip, stale pin, missing
dist, extras, markers, fail-closed None), deferral wiring (marker +
gateway resume + exit 2), placement guards (no detector call pre-fetch;
guard present at git/ZIP sync), and subprocess-verified import hygiene
(import hermes_cli.main and the update --check dispatch never load
cryptography._rust).

Follow-up to #86687 (Halldrix's #83590 salvage — the preflight's intent
stands as defence-in-depth; this makes it fire only when true).

Fixes #86735
Fixes #86780
Fixes #86781
teknium1 added a commit that referenced this pull request Aug 15, 2026
…swap is at risk (#86735, #86780, #86781)

The #86687 self-lock preflight fired on every Windows `hermes update`:
bitwarden.py's module-level cryptography import (fixed in #86782 /
#86826-class change) meant cryptography._rust was ALWAYS mapped by the
time the preflight ran, so the update exited 2 before even fetching and
looped forever — including the Desktop in-app update (#86780).

Two structural fixes so the guard can never re-brick the flow it protects:

1. Version-gated detection: _detect_self_loaded_native_modules() now
   consults _dependency_sync_would_rewrite(dist) — installed version vs
   the on-disk pyproject pins (base deps + all extras, env markers
   honored). A loaded module whose distribution the sync will not touch
   is no lock risk and is not reported. Unknown → fail closed.

2. Relocated deferral: the check no longer runs pre-fetch. It runs via
   _abort_dependency_sync_if_self_locked() immediately before each venv
   rewrite (git-path dep sync, ZIP-path dep sync, current-checkout venv
   repair) — AFTER the code swap. A deferral now leaves the user on NEW
   code with only the dependency install pending (completed by the next
   launch's marker recovery), instead of stranding them on the old
   checkout in an exit-2 loop.

PyYAML's _yaml extension (loaded by every CLI process) joins the
registry — with version gating it is now safe to list.

Tests: version-gate unit coverage (no-change skip, stale pin, missing
dist, extras, markers, fail-closed None), deferral wiring (marker +
gateway resume + exit 2), placement guards (no detector call pre-fetch;
guard present at git/ZIP sync), and subprocess-verified import hygiene
(import hermes_cli.main and the update --check dispatch never load
cryptography._rust).

Follow-up to #86687 (Halldrix's #83590 salvage — the preflight's intent
stands as defence-in-depth; this makes it fire only when true).

Fixes #86735
Fixes #86780
Fixes #86781
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

6 participants