fix(update): close the stale-bytecode class with a launch-time checkout-fingerprint sweep

The stale-.pyc bug class (#6207, #60242, live WhatsApp report: gateway
ImportError 'cannot import name parse_model_flags_detailed' after /update)
has one shared shape: the checkout's .py files change while __pycache__
retains bytecode from the previous revision, and a later process trusts
the stale .pyc.

Update-time clears can never fully close this class: 'hermes update'
always executes the PRE-pull updater code, so hardening added to it takes
effect one update late — and a manual 'git pull' never runs the updater
at all.

Class fix:
- Launch-time guard in main(): compare the checkout fingerprint (cheap
  file reads via _read_git_revision_fingerprint, no git subprocess)
  against a .bytecode-fingerprint stamp; sweep __pycache__ once when they
  diverge. Covers manual pulls, old updaters, ZIP restores — every entry
  point (CLI, gateway service, desktop backend) passes through main().
- Record the stamp at all three update-time clear sites (git path pre-
  install, git path post-install, ZIP path) so a normal update never
  triggers a redundant launch sweep.
- Sibling site: 'hermes plugins update' + dashboard plugin update now
  clear __pycache__ under the plugin dir after git pull (plugin trees
  live outside the repo guard).

E2E validated: reproduced the stale-pyc shadowing (same-size same-mtime
source swap → old symbol wins in a fresh process), confirmed the launch
sweep restores the new symbol, no-ops when the checkout is unchanged,
and re-sweeps on the next pull. Sabotage-tested: reverting the sweep
fails 4 of the new tests.
This commit is contained in:
teknium1 2026-07-26 23:57:41 -07:00 committed by Teknium
parent d76d0d61d8
commit ea2499bcfe
4 changed files with 281 additions and 0 deletions

View file

@ -660,6 +660,11 @@ def cmd_update(name: str) -> None:
console.print(f"[red]Error:[/red] {output}")
sys.exit(1)
# Same stale-bytecode class as the main checkout (#6207/#60242): the
# pull just changed .py files under this plugin dir, so drop any
# __pycache__ compiled from the previous revision.
_clear_plugin_bytecode(target)
# Copy any new .example files
_copy_example_files(target, console)
@ -1954,6 +1959,10 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
if not ok:
return {"ok": False, "error": msg}
# Sibling of the CLI ``hermes plugins update`` path: drop bytecode
# compiled from the pre-pull plugin revision.
_clear_plugin_bytecode(target)
from rich.console import Console
_copy_example_files(target, Console())
@ -1961,6 +1970,30 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
def _clear_plugin_bytecode(target: Path) -> int:
"""Remove ``__pycache__`` dirs under a just-updated plugin checkout.
Plugin dirs live outside the main repo, so the launch-time checkout
fingerprint sweep in ``hermes_cli.main`` never covers them. After a
``git pull`` changes a plugin's ``.py`` files, stale bytecode here can
produce the same ImportError class as #6207/#60242 in whichever
process imports the plugin next. Never raises.
"""
removed = 0
try:
for cache_dir in target.rglob("__pycache__"):
if not cache_dir.is_dir():
continue
try:
shutil.rmtree(cache_dir)
removed += 1
except OSError:
pass
except OSError:
pass
return removed
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
git_exe = _resolve_git_executable()
if not git_exe: