resolve() on both sides also collapses a venv interpreter onto the store
binary it links to, so a caller still running in its old venv stopped
re-executing into the store interpreter (two tests in
tests/pm/test_source_update_launch.py go red). The loop in #122513 is a
spelling difference, not a symlink: PM spells the store Python through a
HERMES_HOME that may contain '..', and the OS reports sys.executable
normalized. normcase(abspath()) matches those spellings and keeps a venv
interpreter distinct.
Tests: the '..' spelling is current (red on main); a venv python symlinked
to the store binary still relaunches (red with resolve()).
An update resolves the enabled plugin union against the NEW core. A plugin
admitted against the old core can stop fitting when core moves (managed
Python 3.13 -> 3.14 vs a member's requires-python <3.14, a requires_hermes
upper bound, a bumped pin), and the whole update then died after the
source swap with a non-resolver InstallError whose 'retry' hint failed the
same way every time.
Update syncs now pass evict_incompatible_plugins=True (update completion,
historical takeover, launch-time completion, venv_sync, post-update
drift). PM screens statically first (requires-python vs the target
interpreter, manifest/requires_hermes), then, if the rest still fails,
builds core alone to prove the plugins are the cause and re-adds members
in config order, disabling each one that breaks the build. Misfits land in
plugins.disabled (memory.provider cleared) in every home that enables
them, published through the existing journaled change hook (the journal
now carries several configs), and are reported on stderr + receipt
warnings. Admission and ordinary syncs still refuse; only a core that
cannot build on its own fails an update.
Under the updater's claim, sync whenever dependencies are not current
rather than only when nothing is committed. The tail's own children
and post-sync verification children are current and stay no-ops. A
stale generation still committed from the previous Python pin is the
same ABI trap as the pre-PM venv, and it now syncs too. If the sync
still leaves the tree out of date, raise instead of relaunching into
another sync.
prepare_launch returned early for any process running under the
updater's own claim, so it would not re-run the completion tail. That
also covered processes the updater spawns before PM commits a
generation (a restarted gateway), which then booted with no
environment: previously on the pre-PM venv, now refused.
Under the updater's claim with nothing committed, sync the dependency
generation (carrying the legacy venv's extras, as the first sync
always has), skip the tail since that belongs to the updater, and
relaunch on the store Python. The relaunched process sees the commit
and returns early as before, so the no-recursion guard still holds.
prepare_launch finishes an interrupted update, or a hand-run git pull, by
syncing the venv at startup. It skipped the required-tool step that
`hermes update` now runs first, so a bumped ripgrep/ffmpeg/python pin stayed
uninstalled and activation warned on every start.
Main-era installs selected [all] and then lazily installed opt-in extras
(FAL, messaging SDKs, ...) into the checkout's venv with no ledger. The
legacy -> PM migration (launch completion and the historical updater
takeover) selected only [all], so the first launch after `hermes update`
prompted "Extra 'fal' requires: fal_client" for a feature that already
worked.
pm.extras.legacy_selection reads the old venv's site-packages (never
imports it) and selects every non-umbrella, platform-supported extra whose
anchors it held. The install prompt that remains for genuinely new
features names the feature instead of Python module names.
Only a manual `hermes pm gc` reclaimed old app and PM runtime generations
on native installs; the Docker boot already collects after its refresh.
A successful venv sync (hermes update and launch-time completion) now runs
the same collectors, which keep selected, leased and day-young generations
and skip when another install holds the lock. Cleanup errors are logged and
never fail the committed update.
b207701287 routed PM's stamp reads through hermes_cli.steward.install_stamp_path.
The Docker runtime base runs PM before hermes_cli ships (only __init__ and
runtime_state are copied), so ensure() died with No module named
'hermes_cli.steward'. The resolver is an install-root path built only on
pm.paths.install_root/repo_root; move it there and point every reader at it.
prepare_launch treated "dependencies current" as "update finished", but the
sync commits the generation before the product builds and the maintenance
run. A crash between the two left a current install that never built anything
and never would. The tail is now owed by a pending marker written before the
sync and cleared after the tail succeeds, so a repeat launch finishes it.
That tail imports the application, whose entry point is this same function:
inside the launching process's update-lock claim (holder pid is our ancestor)
prepare_launch is a no-op, otherwise the marker recursed forever. The claim
also keeps `hermes update` from racing the launch-time completion.
Completion output goes to stderr: it runs in front of whatever the user
typed, which may be emitting machine-readable stdout. Metadata queries
(--version, -V, --help) skip completion entirely; they read no dependencies.
A completion that fails offline no longer exits 1 from hermes_bootstrap: the
previous generation is still selected (a failed sync commits nothing), so
warn, point at `hermes update`, and launch.
hermes_cli.runtime_paths (venv generations, selection, activation) moves to
pm.environments, and gains venv_bin_dir / venv_python / project_python. Every
in-tree caller asks pm for an interpreter now; pm no longer reaches back into
hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure,
pm.paths imported hermes_cli.runtime_paths). The three open-coded
"Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python.
hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths
stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json).
To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562)
and pm.registry loads the built-in package definitions on first read instead of
at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms,
was 37ms with the eager facade dragging in the downloader). The stripped-payload
fixtures that ship only pre-import files keep working for the same reason.
Also restores two frozen-surface re-exports the F401 sweep dropped
(banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
prepare_launch is the startup heal: it detects a stale PM generation
(pm.venv_is_current) and completes the update. But it only synced the
dependency generation and published the launchers -- so an install that
came through it landed in a different state than one that came through a
normal update or the installer.
The tail they share is complete_source_checkout (source_completion.py):
publish launchers, build the products, run the post-build maintenance.
"Its" own docstring: a fresh install and a finished update must land in the
same state, one implementation and two callers. The startup heal was a
third path taking a subset, and the e2e verifier caught exactly that -- it
requires ui-tui/dist fresh, which only build_update_products produces.
So drive the shared tail from the heal, the same way the installers do:
hand source_completion.py THIS interpreter (pre-sync, so it stays a
stdlib-only import for the old tree) and let it re-exec into PM's selected
python before touching any product. A failed tail is loud, matching
build_update_products' "a failed product aborts the update".
Verified: both files compile; the bare-3.11 pre-flip import check still
passes (bootstrap / prepare_launch / takeover_child, no third-party).
publish_launchers returned silently when a tree was sealed, external, or
had no managed interpreter. A PM tree promises its launchers — the e2e
driver refuses to let `hermes --version` paper over the gap — so that
last case is a half-finished update, not a quiet no-op, and it currently
gives no way to tell which of the three conditions fired.
Keep the same three conditions and the same returns; log which one.
The shim renders progress from its status JSON file, but everything
after 'Updating code and dependencies' — the PM dependency sync, Node
deps, the TUI/web/desktop builds — ran for minutes without touching
that file, so the window froze on a stale stage (or showed nothing at
all when the old shim skipped its browser window).
hermes_cli/update_stage.py publishes stages to the watching UI,
std-only and never raising. Two discovery paths: the exported
HERMES_UPDATE_STATUS_FILE (current shim), and, for an OLD shim that
never exported it (every old-to-new checkout transition), the marker's
owner pid, which names the shim whose status file is deterministically
/tmp/hermes-update-status.<pid>. On macOS the takeover child also pops
update-panel.applescript from the freshly pulled tree when the old
shim's log shows it started no renderer.
Publishes: PM sync (_update_takeover.prepare, venv_sync.sync), Node
deps and each product build (source_build.build_update_products).
Only 'running' stages are ever written — terminal states stay the
shim's.
pm.adopt() re-hashed every staged entry and cross-checked shipped facts
against the shipped lock at first boot of a bundled install. The bundle
payload is already proven by the signed artifact at download time; the
first-boot re-hash overlapped that guarantee (and per-boot drift()
stamps) while facts/lock coherence is downstream of CI building the
right bundle in the first place. Defense-in-depth with no incremental
threat model; startup now goes straight to pm.activate().
Drops pm.adopt, its export, the venv_sync check_runtime call, and the
adopt tests + _bundle_payload fixture.
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.
Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.
Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.
Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.
Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
Old updaters keep running after the checkout changes. Returning None
from their removed uv helpers enables a pip fallback against the new tree.
Keep the historical imports as inert shims and stop dependency entrypoints
with a relaunch message instead. Do not call PM or write recovery markers
from that mixed-version process.
Self-managed source launches use PM's successful input stamp to decide
when dependencies need a sync. Restart on the managed interpreter before
activating the selected generation. Preserve launcher forms and options,
and do not sync while a live updater owns the installation.
Targeted runtime batch: 225 passed, 8 platform skips. Real PM worker tests
build and publish disposable dependency generations, retain prior state on
failure, and exercise fresh-process relaunch before dependency activation.
The final launch guard test also passes. Full suite and native Windows
execution were not run locally.
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.
Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.
Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.
Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.
Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.
Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
A lock-only stamp reported current without a usable environment.
Use PM's recorded inputs and boot-time selection for own-tree checks.
Do not read or write the foreign-bootstrap stamp for that path.
PM check and sync now share environment validation. Malformed records
remain intact and produce a visible error instead of a healthy result.
Foreign-root bootstrap and bare-import behavior remain unchanged.
Verification: real temporary PM builds, deletion and replacement,
plugin/Python/extra changes, malformed records and transaction neighbors.
The final isolated gate passed 115 tests with no failures. Lint passed.
No publication-lock rewrite or packaged adoption enforcement included.
Finish bootstrap uv before PM replaces its store entry. Keep failure
receipts stdlib-only and align the cryptography requirement and override
with the locked version.
Let bundle builders declare launch paths and update ownership. Remove
payload discovery, Store probing, and the unused develop command.
Derive Nix Python from the PM lock and share its provenance stamp.
Document setup, activation, optional dependencies, and distribution
ownership. Targeted Windows tests, relocated runtime launches, Electron
bundling, and bilingual docs builds pass. Native Nix and signed-package
acceptance remain CI gates.
Wire the desktop app onto the pm store for real distribution:
- MSIX bundle: electron-builder config, appx assets, manifest, copilot
key + deep-link routing, App Installer + Windows Store variant
(sign only the msix; inner binaries covered by the package block map)
- Rust CLI shim (apps/desktop/shim) — bundled builds run from the store
python + shim, never the venv; payload symlinks relativized so the
relocatable venv survives relocation
- Cloudflare R2 release pipeline: publish binaries + update feeds,
nightly channels/tags, stamp-first version resolution
- Update system: gate, uninstall steward, boot bootstrap, release
channels, update receipts
- install.ps1 reduced to a 361-line stage-protocol bootstrapper (heavy
deps are pm's job); darwin updater + update-channel mirror ripped
- doctor: main's re-landed TCC anchor kept, termux branches removed
Rebuilt from ethie/pm onto the pm-store stack. 22 hot files hand-merged;
uv.lock + package-lock.json keep main's newer dep tree; test_engines
reads the pm/lock.json pin; lazy_deps.py deleted (all 222 importers
migrated to pm in the foundation commit).