fix(update): commit dependencies for processes the update spawns early

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.
This commit is contained in:
ethernet
2026-09-24 22:53:06 -04:00
parent c821ecdd8f
commit 3a42f0fe10
2 changed files with 70 additions and 26 deletions

View File

@@ -252,13 +252,21 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None:
lock = UpdateLock()
if not lock.acquire():
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
# The tail imports the application, whose entry point runs this very function:
# under the launching process's own claim (its pid is our ancestor) we ARE that
# tail and owe nothing — without this, a pending marker recurses forever.
if not lock.acquired and read_live_update() is not None:
return None
try:
_finish_source_update(root, current=current, pending=pending)
# The tail imports the application, whose entry point runs this very function:
# under the launching process's own claim (its pid is our ancestor) we ARE that
# tail and owe nothing — without this, a pending marker recurses forever.
if not lock.acquired and read_live_update() is not None:
from pm.environments import committed_venv
if committed_venv(root) is not None:
return None
# A process the update spawns before PM commits (a restarted gateway) has no
# environment to run on. Commit one — never the tail, which is the updater's —
# then relaunch below; the relaunched process sees the commit and returns above.
_sync_source_dependencies(root, arm=False)
else:
_finish_source_update(root, current=current, pending=pending)
finally:
lock.release()
python = resolve_store_python(root)
@@ -273,9 +281,8 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None:
def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
"""Sync dependencies when they are stale, then run the tail the marker still owes."""
import sys
import pm
from hermes_cli._early_recovery import _marker_owner_is_live
from pm.environments import activation_environment, runtime_facts_path
from pm.environments import activation_environment
if not current:
# Existing markers guard liveness, never create the completion obligation.
@@ -284,24 +291,7 @@ def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
if any(_marker_owner_is_live(marker) for marker in legacy_markers):
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
print("hermes: completing source-update dependencies...", file=sys.stderr, flush=True)
# Owed from before the sync commits: a crash between the commit and the
# tail must leave the tail, not a "current" install with nothing built.
refuse_foreign_owned_venv(root)
arm_completion(root)
# Main-era installs have no PM ledger; carry what their venv held.
# Established PM installs retain their recorded extras and plugin union instead.
from pm.client import ensure_tools_for_sync
from pm.extras import legacy_selection
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
# Same order as `hermes update`: an interrupted update or a hand-run
# `git pull` leaves this tree's lockfile ahead of the installed tools.
ensure_tools_for_sync()
pm.sync_venv(extras, explicit=True, project_root=root)
collect_superseded_generations(root)
# These can predate the swap. Once PM commits the replacement they
# must not make early recovery immediately rebuild it a second time.
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
(root / name).unlink(missing_ok=True)
_sync_source_dependencies(root, arm=True)
else:
print("hermes: finishing an interrupted source update...", file=sys.stderr, flush=True)
# Sync commits the dependency generation, but a source update also owes
@@ -330,6 +320,35 @@ def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
clear_completion(root)
def _sync_source_dependencies(root: Path, *, arm: bool) -> None:
"""Commit the tree's dependency generation; *arm* also owes the tail afterwards."""
import sys
import pm
from pm.client import ensure_tools_for_sync
from pm.environments import runtime_facts_path
from pm.extras import legacy_selection
if not arm:
print("hermes: preparing dependencies for this update...", file=sys.stderr, flush=True)
refuse_foreign_owned_venv(root)
if arm:
# Owed from before the sync commits: a crash between the commit and the
# tail must leave the tail, not a "current" install with nothing built.
arm_completion(root)
# Main-era installs have no PM ledger; carry what their venv held.
# Established PM installs retain their recorded extras and plugin union instead.
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
# Same order as `hermes update`: an interrupted update or a hand-run
# `git pull` leaves this tree's lockfile ahead of the installed tools.
ensure_tools_for_sync()
pm.sync_venv(extras, explicit=True, project_root=root)
collect_superseded_generations(root)
# These can predate the swap. Once PM commits the replacement they
# must not make early recovery immediately rebuild it a second time.
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
(root / name).unlink(missing_ok=True)
def relaunch_command(
python: Path, root: Path, argv: list[str], original: list[str], module: str | None,
) -> list[str]:

View File

@@ -268,6 +268,31 @@ def test_launch_without_marker_publishes_then_skips_and_rebuilds_on_lock_change(
assert not (root / ".update-incomplete").exists()
@pytest.mark.platforms("posix")
def test_process_spawned_by_the_update_commits_dependencies_but_not_the_tail(source_launch, tmp_path):
"""A process an update spawns before PM commits (its restarted gateway) must not boot on the
pre-PM venv: that tree was built for the old interpreter and loses its compiled modules."""
import time
from hermes_cli.update_lock import update_marker_path
from pm.environments import committed_venv
root, store_python, _ = source_launch
marker = update_marker_path()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(f"{os.getppid()}\n{int(time.time())}\n", encoding="utf-8") # the updater is our ancestor
assert committed_venv(root) is None
assert venv_sync.prepare_launch(root, []) == store_python
assert committed_venv(root) == Path(_fact(root)["environment"])
assert not (tmp_path / "completion-calls").exists(), "the tail is the updater's, not its child's"
assert not venv_sync.completion_pending_path(root).exists()
facts_bytes, receipts = runtime_facts_path(root).read_bytes(), _receipts(tmp_path)
venv_sync.prepare_launch(root, [])
assert runtime_facts_path(root).read_bytes() == facts_bytes
assert _receipts(tmp_path) == receipts, "a committed child synced again under the updater's claim"
@pytest.mark.platforms("posix")
def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch, tmp_path):
root, store_python, _ = source_launch