Files
hermes-agent/hermes_cli/source_completion.py
Teknium a66193a8c7 fix(update): A7 marker mutex, one incarnation rule, delegate-aware release, checkout custody
R3 / A7 rule 1: every marker decision-and-mutation (reclaim of a dead claim,
adopt + delegate append, release, ownership hand-over) now runs while holding
the kernel lock on the never-deleted sidecar `<marker>.lock` (POSIX flock;
Windows: the file opened share-none, the same lock the PowerShell hand-off
takes), bounded to 10 s. Only the exclusive first publish stays lock-free
(rule 2). Python (update_lock.marker_mutex) and Rust (marker.rs lock_marker)
interoperate on the same lock. A stale reader paused before its unlink can no
longer delete a claim published after its read.

A7 rule 4 / R12: one incarnation rule. Our own pid is ours only within 5 ms of
our creation time; a no-ct claim naming our pid is a previous incarnation
(dead) — in Python (`update_lock.incarnation_live`, exported for the pause
record) and in marker.rs (no more 2 s self tolerance; Electron's v1 own-pid
pre-write is reclaimed and republished as v2; the exit-2 heal requires our
exact incarnation).

A7 rule 5: release is identity-based (`_release_decision` / `release_decision`).
The owner removes its claim regardless of delegate lines unless a LIVE
delegate other than itself exists, which then inherits the claim (rewritten
with the delegate as owner, started_at and run lines kept). A delegate drops
its line while the owner lives, else deletes. This is the native Windows
`orphaned_update` root cause: the hand-off script named `hermes update` as
delegate before it claimed, the child adopted without writing, and its exit
released nothing, leaving '<dead owner> ... delegate:<exited child>'.

Parser (A7 rule 7): lines 4+ are tagged — first well-formed delegate line,
every `run:<id>` line; pid must fit u32. Both consumers run
tests/fixtures/update_marker_corpus.json (judge + release cases).

R2: checkout mutators keep custody. `_git_run` passes the held checkout lock
fd to git (and its hooks); `run_source_script` passes it to the Node build;
`complete_source_checkout` ACQUIRES `UpdateLock(install_root=root)` instead of
claiming only its home's marker; venv_sync's interrupted-update finish acquires
the checkout lock before mutating instead of sampling it and hands the fd to
its completion child; the historical takeover chain passes it down. On Windows
a child of the update joins the lock when its holder record names a live
ancestor (it runs in that owner's kill-on-close job).

R11: a live pre-root-marker claim under <root>/profiles/*/ (the old per-profile
marker) is observed: a new update refuses while one is live and not a partner.

Real-process tests: tests/hermes_cli/test_update_lock_protocol.py (each fails on
6ff4055cd74), corpus consumers in Python and cargo.
2026-10-06 04:36:02 -07:00

217 lines
9.5 KiB
Python

"""The tail every source install and update shares.
A fresh install (``scripts/install.sh`` / ``scripts/install.ps1``) and a finished
update must land in the same state: launchers published, product builds current,
and the post-build maintenance (skills sync, config migration) applied. One
implementation, two callers -- an install and an update cannot drift apart.
``main()`` is the installer entry. It is handed the bootstrap interpreter the
installer happens to have (uv, PM and no application dependencies), so it
re-executes itself in PM's selected interpreter before touching any product.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
# The installers run this file directly under an isolated interpreter, which
# leaves the checkout off sys.path. Same idiom as hermes_cli/_launchers.py.
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
#: Second-phase marker: the re-executed interpreter, not the bootstrap one.
_PREPARED = "--prepared"
def complete_source_checkout(
root: Path,
*,
desktop: bool,
assume_yes: bool,
gateway_mode: bool = False,
pre_update_snapshot_id: str | None = None,
pre_update_version: str | None = None,
completion_message: str | None = None,
announce: str | None = None,
followups: list[tuple[str, str]] | None = None,
) -> bool:
"""Publish commands, build the products, then run post-build maintenance.
Every step runs even when an earlier one failed; each failure is printed as ``⚠``,
recorded on the open update receipt and appended to ``followups`` as ``(step, reason)``.
Returns True only when every step finished and the SQLite runtime is not unsafe.
"""
from hermes_cli.update_lock import UpdateLock, describe_holder
root = Path(root)
# This tail is the last mutating step of an install or update, and its product
# builds run for minutes. A gateway restarted while it runs (launchd KeepAlive,
# a service manager, a second `hermes` launch) reaches the same tail through
# venv_sync, and `hermes update` runs its own: two completions then build the
# same output directories concurrently and race on install-stamp.json (#123376).
# Claim the shared update lock so stacked completions serialize. A tail whose
# orchestrator already holds the lock (venv_sync's interrupted-update finish,
# the updater's completion child) runs under its parent's claim, exactly as
# `hermes update` does under the desktop handoff pid. The CHECKOUT lock is acquired, not
# sampled (R2): a completion from another home must not build a checkout an update owns;
# one inside the update's tree joins the lock it inherited.
lock = UpdateLock(install_root=root)
if not lock.acquire():
raise RuntimeError(
f"an update is still running ({describe_holder(lock.holder)}); "
"wait for it to exit, then relaunch Hermes"
)
try:
return _complete_locked(
root, desktop=desktop, assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
pre_update_version=pre_update_version,
completion_message=completion_message, announce=announce, followups=followups,
)
finally:
lock.release()
def _complete_locked(
root: Path,
*,
desktop: bool,
assume_yes: bool,
gateway_mode: bool,
pre_update_snapshot_id: str | None,
pre_update_version: str | None,
completion_message: str | None,
announce: str | None,
followups: list[tuple[str, str]] | None = None,
) -> bool:
"""The completion body; callers hold the update lock already."""
from hermes_cli.source_build import build_update_products
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
from hermes_cli.venv_sync import publish_launchers
try:
from hermes_cli._subprocess_compat import expose_pm_git
# The builds, the release-history refresh and the install stamp all run
# git; a fresh Windows machine has only PM's.
expose_pm_git(root)
except Exception as exc: # noqa: BLE001 — git-less steps below still complete
print(f"⚠ Could not provide git for the source completion: {exc}", file=sys.stderr)
owed: list[tuple[str, str]] = []
def step(name: str, run) -> None:
# Each step is independent: a failed launcher publish or web build must not skip the
# config migration, the maintenance, or (in an update) the gateway restart after it.
try:
run()
except (Exception, SystemExit) as exc: # health: allow BLE001 -- reported as an owed follow-up
from hermes_cli.update_receipt import record_followup
reason = str(exc) or type(exc).__name__
record_followup(name, reason)
owed.append((name, reason))
step("launchers", lambda: publish_launchers(root))
step("build", lambda: build_update_products(root, desktop=desktop))
if announce:
print(announce)
verdict: list[bool] = []
step("maintenance", lambda: verdict.append(_run_post_update_maintenance(
assume_yes=assume_yes,
gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=desktop,
pre_update_version=pre_update_version,
completion_message=completion_message,
followups=owed,
)))
tail_done = not owed
if tail_done:
from hermes_cli.source_stamp import write_source_stamp
try:
write_source_stamp(root)
except (OSError, ValueError) as exc:
print(f"⚠ Source update completed, but the install stamp could not be written: {exc}",
file=sys.stderr)
if followups is not None:
followups.extend(owed)
# The SQLite verdict is not tail work (re-running the tail cannot fix the interpreter); it
# is reported by the maintenance step itself and only withholds an install's success.
return tail_done and all(verdict)
def _bootstrap_command(root: Path, argv: list[str]) -> list[str]:
"""Re-enter the checkout on PM's selected interpreter with its environment."""
from pm.environments import project_python
return [str(project_python(root)), "-I", "-B", "-u",
str(Path(__file__).resolve()), "--source", str(root), *argv, _PREPARED]
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
prepared = _PREPARED in argv
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--desktop", action="store_true",
help="Also build the packaged desktop app.")
parser.add_argument("--interactive", action="store_true",
help="Allow prompts; installers pass no flag and stay unattended.")
parser.add_argument("--finish-update", action="store_true",
help="Complete a source UPDATE tail instead of an install: the "
"same launchers/products/maintenance with update wording.")
args = parser.parse_args([argument for argument in argv if argument != _PREPARED])
root = args.source.resolve()
if not (root / "hermes_cli/source_completion.py").is_file():
print(f"✗ {root} is not a Hermes source checkout", file=sys.stderr)
return 1
if prepared:
# Dependencies are selected before any application import: this is the
# same ordering the update completion guarantees.
from pm.environments import activate_dependencies
activate_dependencies(root)
if args.finish_update:
# A source update that never reached its own completion -- a
# pre-handoff release cannot flip during `hermes update`, so its
# update ends with the tree at HEAD and nothing built -- lands here
# on the next ordinary startup. Same tail as an install, so the two
# states cannot drift apart. Only owed TAIL work keeps it pending: an
# unsafe SQLite runtime is reported, but re-running the tail cannot fix it.
owed: list[tuple[str, str]] = []
complete_source_checkout(
root, desktop=args.desktop, assume_yes=True,
completion_message=None, announce="\n✓ Code updated!", followups=owed,
)
ok = not owed
else:
ok = complete_source_checkout(
root, desktop=args.desktop, assume_yes=not args.interactive,
completion_message="✓ Install complete!",
)
return 0 if ok else 1
from pm.environments import activation_environment
# This interpreter is a bootstrap one: the work happens in the re-exec, so
# every flag that decides WHICH tail runs has to survive into it. Losing
# --finish-update here silently reports an update as an install.
passthrough = (["--desktop"] if args.desktop else []) + \
(["--finish-update"] if args.finish_update else [])
command = _bootstrap_command(root, passthrough)
from hermes_cli.update_lock import checkout_lock_fds
# Called inside an update tree (venv_sync's interrupted-update finish), the prepared child
# keeps the checkout lock this bootstrap inherited.
fds = checkout_lock_fds(root)
return subprocess.call(command, cwd=root, env=activation_environment(root),
**({"pass_fds": fds} if fds else {}))
if __name__ == "__main__":
raise SystemExit(main())