Files
hermes-agent/hermes_cli/update_pause_record.py
Teknium 92189e2d0a fix: POSIX update pause drains turns first and survives a killed update cleanly
Review fixes for the pre-swap gateway pause (independent review of a029ec2):

- Every gateway is drained before the code swap: the control socket's
  pause-for-update runs the request_restart drain (the post-update restart's
  SIGUSR1 path) and the update waits within the fleet's canonical budget
  (_gateway_drain_budget, 1905 s by default) with progress lines. systemd
  units and launchd jobs are stopped through their supervisor only after
  their drained gateway has exited, which also keeps Restart=/KeepAlive from
  relaunching it mid-update. A gateway that does not take the drain gets the
  supervisor stop / SIGTERM as before. The request is recorded (stop_sent)
  before it is sent and carries no planned-stop marker: the marker watcher
  takes the immediate signal-stop path and could fire first. Before: a
  supervised gateway got systemctl stop / bootout at once
  (restart_drain_timeout=0 cuts an in-flight turn), a bare one was SIGKILLed
  after 70 s.
- The pause record stores canonical identities (pause_record.identity), so
  recovery recognises a still-live gateway; the native birth stamp stays on
  the entry for _alive.
- Supervised posix_units go through drop_never_stopped / split_draining /
  backoff / hand-back like every other entry: a killed update no longer owes a
  live unit it never stopped, nor relaunches one still draining.
- Unknown cgroup membership (either side unreadable) never authorises
  stopping the unit; cgroup v1 name=systemd is read when v2 is absent.
- A record that cannot be written restores the token exactly (fresh or
  adopted), so the checkout's tree gate is not armed by a pause that never
  happened.
- A replayed bare gateway's environment follows the restart watcher it
  replaces: the launch profile's own named home keeps the launcher env (an
  exported bot token can be its only credential); every other home and the
  multiplexing root get the served-profile factory.
- Resuming a paused unit runs the definition repair the fleet restart runs
  first (_repair_unit_without_fatal_exit_park; launchd plist refresh).
- Shared POSIX tests use platforms("posix") instead of a module skipif, so
  the macOS lane imports them.

e2e cell (e) now reads the recorded stop request (record stop_sent) instead of
the planned-stop marker, which the drain path no longer writes.
2026-10-07 04:39:00 -07:00

1038 lines
51 KiB
Python

"""Durable record of the gateways and services ``hermes update`` paused on Windows.
The pause token used to live only in the updater's memory (plus an ``atexit`` resume), so a
killed updater (console closed, ``taskkill``, power loss) left every paused gateway and SCM
service stopped with nothing on disk saying so. The record is written under the ROOT Hermes
home BEFORE anything is stopped, names its owner (pid + creation time, the update-marker
identity format), is rewritten as the token changes, and is removed only after a verified
resume. A later launch whose owner check finds the updater dead (and no update live) resumes it.
One record per checkout (the file name carries a key of the install root): a checkout sharing
the home never imports, relabels or certifies another checkout's paused set — its tree gate
says nothing about the other tree.
Resuming is gated on a whole tree: no interrupted-pull marker; at the pre-update HEAD, no tracked
change beyond the ones present at pause time (git died before moving HEAD); at a moved HEAD,
dependencies current for it where a launch syncs them (a build step may rewrite tracked files there). Every pause folded into
a record keeps its own baseline (``baselines``): each one taken at the current HEAD must hold.
Otherwise the record stays for the next launch, which runs after the interrupted-pull restore and
the dependency sync.
Custody: every mutation (write, claim, retire, discharge) happens while holding a kernel lock on
``<record dir>/.hermes-update-paused-gateways.lock`` (flock / msvcrt, released by the kernel when
the holder dies; the file is never deleted), and the read → judge → mutate decision is made
inside that hold. A recovering launch claims a record by publishing ``<record>.<pid>.<nonce>.claim``
with its own identity already inside (atomic replace), then retires the source; a crash between the
two leaves two files with ONE obligation id (``pause_id``), and an update that folds orphaned sets
into its own record lists their ids under ``absorbed`` before retiring them. Readers treat a file
whose id another file carries as a copy, never as a second obligation, and the next mutator
retires it. Completing an obligation first adds its ids to ``<record stem>.retired`` (atomic), then
unlinks its files: an unlink Windows refuses (a reader holding the file without
FILE_SHARE_DELETE) leaves a copy that is redundant by id, never an obligation that executes again.
The list is read only under the mutex, and only its absence means "nothing retired": a list that
cannot be read means unknown, so nothing is claimed and the list is never rewritten from that read.
A list that reads but does not parse is unknown too while any record or claim exists; with none
left it guards nothing and is set aside as ``<record stem>.corrupt``.
"""
from __future__ import annotations
import glob
import hashlib
import json
import os
import shutil
import subprocess
import sys
import threading
import time
import uuid
from contextlib import contextmanager, redirect_stdout, suppress
from pathlib import Path
RECORD_STEM = ".hermes-update-paused-gateways"
MUTEX_NAME = RECORD_STEM + ".lock"
_MUTEX_WAIT_S = 10.0
# Re-entry depth per THREAD: another thread of this process (a gateway's consumer, a watcher)
# must wait for the kernel lock like any other holder, never ride on this thread's hold.
_mutex_held = threading.local()
def install_root() -> Path:
return Path(__file__).resolve().parents[1]
def _install_key(root: Path | None = None) -> str:
return hashlib.sha256(os.path.normcase(str(root or install_root())).encode("utf-8")).hexdigest()[:12]
def record_path() -> Path:
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / f"{RECORD_STEM}.{_install_key()}.json"
def identity(pid: int | None = None) -> dict:
"""``{"pid", "ct"}`` with ``ct`` spelled like the update marker's line 3 (``ct:<s.3f>``).
The creation time comes from the probe :func:`identity_is_live` judges with
(``update_lock.process_create_time``: psutil, else stdlib), so a host without psutil still
writes a provable identity instead of ``ct=None``."""
from hermes_cli import update_lock
pid = os.getpid() if pid is None else int(pid)
created = update_lock.process_create_time(pid)
return {"pid": pid, "ct": None if created is None else f"ct:{created:.3f}"}
def identity_is_live(ident: dict | None) -> bool:
"""The update marker's incarnation rule (``update_lock.incarnation_live``) for a recorded identity.
Unprovable (alive, no readable creation time) counts as live: a record is never taken from, nor
a paused process restarted over, a process that may still be the one recorded.
"""
from hermes_cli import update_lock
ident = ident or {}
return update_lock.incarnation_live(ident.get("pid") or 0, ident.get("ct") or None) is not False
class RecordConflict(OSError):
"""The record names another pause whose owner is still live."""
class RecordBusy(OSError):
"""Another process held the record mutex past the bounded wait."""
class RetiredUnknown(OSError):
"""The retired-id list exists but cannot be read: which obligations are complete is unknown."""
class RecordUnknown(OSError):
"""A saved pause or claim exists but cannot be read or parsed: what it still owes is unknown."""
@contextmanager
def _mutex(wait_s: float = _MUTEX_WAIT_S):
"""Exclusive kernel lock on the record directory's sidecar (A7). Re-entrant per thread; each
thread's first entry opens its own descriptor, so threads exclude each other like processes."""
depth = getattr(_mutex_held, "depth", 0)
if depth:
_mutex_held.depth = depth + 1
try:
yield
finally:
_mutex_held.depth -= 1
return
from hermes_cli import update_lock
path = record_path().with_name(MUTEX_NAME)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o644)
try:
deadline = time.monotonic() + wait_s
while not update_lock._try_lock(fd):
if time.monotonic() > deadline:
raise RecordBusy(f"{path} is held by another process or thread")
time.sleep(0.05)
_mutex_held.depth = 1
try:
yield
finally:
_mutex_held.depth = 0
update_lock._unlock(fd)
finally:
os.close(fd)
def _git_executable() -> str:
"""The git ``hermes update`` runs, found without installing anything: PATH's, else the copy
install.ps1 staged in PM's store (``_subprocess_compat.expose_pm_git`` puts it on PATH only
inside the updater). A bare ``git`` on such an install dies with WinError 2, so every gate
read would fail and the paused gateways would never be restarted."""
found = shutil.which("git")
if found:
return found
with suppress(Exception): # health: allow BLE001 -- no PM, no store, no Windows git package: PATH's answer ("git") stands
import pm
from pm import paths
from pm.lock import Lockfile
recorded = pm.installed_package("git", allow_outdated=True)
if recorded is not None and recorded.binary is not None and recorded.binary.is_file():
return str(recorded.binary)
package, target = pm.get_package("git"), pm.current_target()
version = Lockfile(paths.lockfile_path()).version("git")
staged = package.binary(paths.store_root() / package.store_entry(version, target), target)
if staged is not None and staged.is_file():
return str(staged)
return "git"
def _git(root: Path, *args: str) -> subprocess.CompletedProcess | None:
"""A read-only git query in the updater's custody (``update_custody.run_git``: job-bound on
Windows inside an update, custody config in argv); ``None`` when git cannot run."""
from hermes_cli.update_custody import run_git
try:
return run_git([_git_executable(), "-C", str(root)], args, capture_output=True, text=True,
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, timeout=30, check=False)
except (OSError, ValueError, subprocess.SubprocessError):
return None
def head_sha(root: Path) -> str | None:
result = _git(root, "rev-parse", "--verify", "HEAD")
if result is None or result.returncode != 0:
return None
return result.stdout.strip() or None
def tracked_changes(root: Path) -> list[str] | None:
"""Tracked paths that differ from HEAD (index or worktree); ``None`` when git cannot say."""
result = _git(root, "status", "--porcelain=v1", "-z", "--untracked-files=no")
if result is None or result.returncode != 0:
return None
paths = set()
entries = result.stdout.split("\0")
index = 0
while index < len(entries):
entry = entries[index]
index += 1
if len(entry) < 4:
continue
paths.add(entry[3:])
if entry[0] in "RC": # rename/copy: the source path follows as its own entry
index += 1
return sorted(paths)
def stamp_tree(token: dict, root: Path | None = None) -> dict:
"""Record what "the tree before this update" was, for the whole-tree gate: one baseline per
pause (idempotent for this token's ``pause_id``), next to the baselines of the pauses it absorbed."""
root = root or install_root()
pause_id = token.setdefault("pause_id", uuid.uuid4().hex)
baselines = token.setdefault("baselines", [])
if not any(b.get("pause_id") == pause_id for b in baselines) and (state := tree_state(root)):
baselines.append({"pause_id": pause_id, **state})
return token
def tree_state(root: Path) -> dict | None:
"""HEAD, the tracked changes and their bytes now; ``None`` when *root* is not a git checkout."""
if not (root / ".git").exists():
return None
dirty = tracked_changes(root)
# The bytes, not just the names: an autostashed edit and git's half-written bytes share a path.
return {"pre_sha": head_sha(root), "dirty_at_pause": dirty,
"dirty_digests": {path: _digest(root / path) for path in dirty or []}}
def _digest(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError: # deleted (or unreadable) at pause: it must read the same way to count as unchanged
return None
def mark_move(token: dict | None, target: str) -> None:
"""Persist, BEFORE git writes the tree, the commit this run's checkout move goes to, on this
pause's own baseline: an unmoved HEAD is later judged against what THAT move could write, never
against refs a later fetch replaced. Raises when it cannot be recorded: the move must not run."""
if not token or not token.get("pause_id") or not target:
return
for baseline in token.get("baselines") or []:
if baseline.get("pause_id") == token["pause_id"]:
baseline["move_targets"] = sorted({*baseline.get("move_targets", []), target})
write({**token, "resume_needed": True})
_MAX_MOVE_TARGETS = 16
def _paths_between(root: Path, targets) -> set[str] | None:
"""Tracked paths that differ between HEAD and any of *targets* — all a fast-forward, reset or
merge toward them can write; ``None`` when git cannot say."""
paths: set[str] = set()
for target in sorted(targets):
diff = _git(root, "diff", "--name-only", "-z", "--no-renames", "HEAD", target, "--")
if diff is None or diff.returncode != 0:
return None
paths.update(filter(None, diff.stdout.split("\0")))
return paths
def _paths_a_move_could_write(root: Path) -> set[str] | None:
"""For a pause with no recorded move (:func:`mark_move`: none attempted, or a record from before
it was kept): the paths between HEAD and a commit the checkout fetched (``FETCH_HEAD``) or tracks
(``refs/remotes``). ``None`` when git cannot say or nothing was fetched (target unknown)."""
where = _git(root, "rev-parse", "--git-path", "FETCH_HEAD")
refs = _git(root, "for-each-ref", "--format=%(objectname)", "refs/remotes")
if where is None or where.returncode != 0 or refs is None or refs.returncode != 0:
return None
try:
fetched = (root / where.stdout.strip()).read_text(encoding="utf-8-sig", errors="replace").split("\n")
except FileNotFoundError:
fetched = []
except OSError:
return None
targets = {line.split()[0] for line in fetched if line.split()} | set(refs.stdout.split())
if not targets or len(targets) > _MAX_MOVE_TARGETS:
return None
return _paths_between(root, targets)
def _seen_at_pause(root: Path, baseline: dict, path: str) -> bool:
"""*path* is as the pause saw it: dirty then, with the same bytes now (a baseline from before
digests were kept has only the name)."""
if path not in (baseline.get("dirty_at_pause") or []):
return False
digests = baseline.get("dirty_digests")
return digests is None or digests.get(path) == _digest(root / path)
def _half_written(root: Path, changes: list[str], at_head: list[dict]) -> list[str]:
"""Tracked changes at an unmoved HEAD that git may have left half-written, for the first
baseline that has any. A change the pause did not see counts only on a path the update's move
could write: a build/sync step (or the user) rewriting any other tracked file is not git's
doing, and holding the set for it would keep gateways stopped until someone cleans the file.
The move is the one recorded before it ran, so a later fetch never narrows it; with none
recorded the fetched refs stand in, and when even those are unknown every unseen change counts."""
for baseline in at_head:
unexpected = sorted(path for path in changes if not _seen_at_pause(root, baseline, path))
if not unexpected:
continue
# move_paths: files a step rewrote toward this HEAD (stash push/apply, reset, checkout --).
targets, paths = baseline.get("move_targets"), baseline.get("move_paths")
writable = _paths_between(root, targets) if targets else set() if paths else _paths_a_move_could_write(root)
torn = unexpected if writable is None else [path for path in unexpected if path in {*writable, *(paths or ())}]
if torn:
return torn
return []
def torn_by_move(root: Path, found: dict, changes: list[str] | None) -> list[str] | None:
"""Paths a move from *found*'s HEAD to the current one wrote that do not hold the current
commit's bytes: tracked changes on them that were not already there, byte for byte, when the
move started. ``None`` when git cannot say."""
writable = _paths_between(root, [found["pre_sha"]]) if found.get("pre_sha") else None
if writable is None or changes is None:
return None
return sorted(path for path in changes if path in writable and not _seen_at_pause(root, found, path))
def _landed_torn(root: Path, head: str, changes: list[str], landed: list[dict]) -> list[str]:
"""Tracked changes at a HEAD a recorded move reached that are not that commit's bytes. git can
move HEAD past a file it failed to write (``checkout`` exits 0 after "unable to unlink old"),
so a moved HEAD vouches for nothing by itself. The verdict the updater took the moment git
returned stands while those paths stay changed: a later dependency sync or stash restore
rewriting a file is not the move's doing. A move the updater died before judging is judged now."""
for baseline in landed:
verdict = (baseline.get("landed") or {}).get(head)
if verdict is None:
verdict = torn_by_move(root, baseline, changes)
torn = list(changes) if verdict is None else [path for path in verdict if path in changes]
if torn:
return torn
return []
def _deps_hold_resume(root: Path) -> bool:
"""At a moved HEAD: are stale dependencies a reason to keep the paused set stopped?
Only where waiting converges. A self-managed install (``updateMechanism: self``) syncs its
dependencies at the start of every launch (``venv_sync.prepare_launch``), before startup
recovery runs, so a deferred set comes back on the next launch. Anywhere else nothing a launch
does makes them current (a developer checkout owns its own venv): the set resumes now, as it
did before the pause was durable, and a currency the probe cannot establish never holds it
either — the restarted gateway's own launch syncs or reports the remedy.
"""
if (os.environ.get("HERMES_DISABLE_LAZY_INSTALLS", "").lower() in ("1", "true", "yes")
or not (root / ".git").exists() or not (root / "pyproject.toml").is_file()):
return False # prepare_launch skips the sync: waiting would never end
try:
from hermes_cli.steward import read_install_stamp
if read_install_stamp(root).get("updateMechanism") != "self":
return False
import pm
return not pm.venv_is_current(project_root=root)
except Exception: # health: allow BLE001 -- unknown currency must not strand the set: resume, the gateway's own launch syncs or names the remedy
return False
def tree_is_whole(token: dict, root: Path | None = None) -> tuple[bool, str]:
"""May paused gateways start on this checkout now? ``(verdict, reason when not)``."""
from hermes_cli._early_recovery import interrupted_pull_marker
root = root or install_root()
if (root / ".git").exists():
if interrupted_pull_marker(root).exists():
return False, "the checkout is mid-pull (interrupted-pull marker present)"
head = head_sha(root)
if head is None:
return False, "the checkout HEAD is unreadable"
baselines = token.get("baselines") or []
at_head = [b for b in baselines if b.get("pre_sha") == head]
# HEAD reached by a move these pauses recorded: judged against that commit's tree.
landed = [b for b in baselines if b.get("pre_sha") != head
and (head in (b.get("landed") or {}) or head in (b.get("move_targets") or []))]
if at_head or landed:
# HEAD never moved for the at_head pauses: a tracked change one of them did not see is
# git's half-written checkout. Each is judged on its own set — a killed run that moved
# HEAD elsewhere vouches for nothing here, and this run's set never certifies an older one's.
changes = tracked_changes(root)
if changes is None:
return False, "git cannot read the checkout state"
torn = _half_written(root, changes, at_head) or _landed_torn(root, head, changes, landed)
if torn:
return False, f"the checkout has {len(torn)} file(s) git left half-written (e.g. {torn[0]})"
if at_head:
return True, ""
if _deps_hold_resume(root):
return False, "dependencies are not current for the updated code yet"
return True, ""
def _atomic_write(path: Path, body: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(body, fh, indent=1, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
# A refused replace (a Windows reader without FILE_SHARE_DELETE) or a failed write must
# not leave the half-published temp beside the record; the error itself still propagates.
with suppress(OSError):
tmp.unlink()
raise
def read(path: Path | None = None) -> dict | None:
"""The saved body; ``None`` only when the file is absent. One that exists but cannot be read
(a Windows sharing violation, an AV scanner) or does not parse raises :class:`RecordUnknown`:
it may be the newest copy of an obligation (its partial progress) or the only one, so it never
lets an older copy execute nor a new pause replace it. Callers fail closed and retry later."""
path = path or record_path()
try:
text = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return None
except OSError as exc:
raise RecordUnknown(f"cannot read the paused-gateway record {path}: {exc}") from exc
try:
body = json.loads(text)
except ValueError:
body = None
if not isinstance(body, dict) or not isinstance(body.get("token"), dict):
raise RecordUnknown(f"the paused-gateway record {path} is malformed; fix or remove it to resume")
return body
UNOWNED = {"pid": 0, "ct": None}
def write(token: dict, *, owner: dict | None = None, path: Path | None = None) -> None:
"""Persist *token*. The owner of the same pause is kept; another pause's orphaned set is merged
into *token* (in place, so this run's resume brings it back); a live one refuses."""
path = path or record_path()
with _mutex():
existing = read(path)
same = existing is not None and existing["token"].get("pause_id") == token.get("pause_id")
if existing is not None and not same:
if existing.get("install_root") != str(install_root()):
raise RecordConflict(f"{path} holds gateways paused for another checkout ({existing.get('install_root')})")
if identity_is_live(existing.get("owner")):
raise RecordConflict(f"{path} holds gateways paused by live process {existing['owner'].get('pid')}")
merge_into(token, drop_never_stopped(dict(existing["token"])))
if owner is None:
owner = existing["owner"] if same and existing is not None else identity()
_atomic_write(path, {"schema": 1, "owner": owner, "install_root": str(install_root()), "token": token})
def mark_stop_requested(token: dict, pids, markers: dict | None = None) -> None:
"""Persist, BEFORE the first stop request, the stops this update intends and where each mapped
gateway's request (its planned-stop marker) will be written. Intent is not a request: a live
process counts as asked only once the producer or consumer checkpoints it, or while the
valid request this update wrote is still on disk (:func:`_asked`)."""
token["stop_requested"] = sorted({*map(str, token.get("stop_requested") or []), *(str(int(p)) for p in pids)})
token["stop_sent"] = sorted(map(str, token.get("stop_sent") or []))
token["stopper_pid"] = os.getpid()
token["stop_markers"] = {**(token.get("stop_markers") or {}), **{str(int(p)): str(m) for p, m in (markers or {}).items()}}
write(token, owner=identity())
def mark_stop_sent(token: dict, pid) -> None:
"""Persist that *pid*'s stop request was issued (its planned-stop marker is on disk, or the
POSIX pause records it just before its socket request): from here a live *pid* is draining and
keeps its restart debt until it exits. Best effort: the marker itself is evidence until this
lands; its consumer checkpoints before unlinking it."""
token["stop_sent"] = sorted({*map(str, token.get("stop_sent") or []), str(int(pid))})
try:
write(token, owner=identity())
except OSError as exc:
print(f" ⚠ Could not record the stop request for gateway {pid}: {exc}")
def mark_stop_consumed(path: Path, marker: dict) -> None:
"""Checkpoint a validated gateway consume BEFORE its marker disappears.
Called under the pause mutex by the consumer. Preserve the holder's identity;
this is evidence of a stop, not a transfer of the updater's custody.
"""
from gateway.status import _same_hermes_home
pid = str(marker["target_pid"])
for src, body in _files(record_path()):
token = body["token"]
recorded_path = (token.get("stop_markers") or {}).get(pid)
if (str(token.get("stopper_pid")) != str(marker.get("stopper_pid"))
or pid not in (token.get("stop_requested") or [])
or not recorded_path or not _same_hermes_home(recorded_path, path)):
continue
token["stop_sent"] = sorted({*map(str, token.get("stop_sent") or []), pid})
_atomic_write(src, body) # Failure leaves the request intact for recovery.
def _accepted_path(marker_path: Path) -> Path:
return marker_path.with_name(marker_path.name + ".accepted")
def mark_stop_accepted(path: Path, marker: dict) -> None:
"""The consumer's receipt when it cannot checkpoint into the record (busy mutex, refused
replace): this incarnation accepted the request and drains until it exits, however long past
the request's TTL. Written beside the marker, in the gateway's own home, never into the record.
A home that refuses the new file (an ACL, a read-only directory) still lets the kept request be
rewritten in place: stamped ``accepted`` it is the receipt. Torn mid-write it reads malformed,
which recovery holds as unknown, never as "not asked". The consumer accepts the stop either way."""
try:
_atomic_write(_accepted_path(path), dict(marker))
return
except OSError as exc:
refused = exc
try:
with open(path, "r+", encoding="utf-8") as fh: # r+: never creates a request that was not there
json.dump({**marker, "accepted": True}, fh)
fh.truncate()
fh.flush()
os.fsync(fh.fileno())
except OSError as exc:
print(f" ⚠ Could not record the accepted stop request {path}: {refused}; {exc}", file=sys.stderr)
def discharge(token: dict, path: Path | None = None) -> None:
"""Remove the record only while it still names this pause (decided under the mutex)."""
path = path or record_path()
with _mutex():
existing = read(path)
if existing is not None and existing["token"].get("pause_id") == token.get("pause_id"):
_retire(path, [(path, existing)])
def sync(token: dict) -> None:
"""Mirror the token after a resume attempt: done → delete; anything still owed → rewrite."""
if not token.get("pause_id") or token.get("recovery"):
return # a recovering launch keeps what it owes in its own claim (_resume_claimed)
try:
if token.get("resume_needed") or token.get("resume_deferred"):
owed = {**token, "resume_needed": True}
owed.pop("resume_deferred", None)
write(owed)
else:
discharge(token)
except OSError as exc:
print(f" ⚠ Could not update the paused-gateway record {record_path()}: {exc}")
def _live_update_elsewhere() -> bool:
"""A live update other than the one this process belongs to. The update tree holding the
checkout lock (this process plus the partner whose marker it adopted) is not "elsewhere"."""
from hermes_cli import update_lock
root = install_root()
return not update_lock.holds_checkout_lock(root) and update_lock.update_in_progress(root)
def _claims(path: Path) -> list[Path]:
return sorted(path.parent.glob(f"{glob.escape(path.name)}.*.claim"))
def _retired_path(path: Path) -> Path:
return path.with_suffix(".retired")
def _retired(path: Path) -> set[str]:
"""The completed obligation ids (call under the mutex). Only an absent list is empty: one that
exists but cannot be read raises :class:`RetiredUnknown`, so a completed obligation is never
claimed again and the list is never rewritten from a read that failed."""
target = _retired_path(path)
try:
text = target.read_text(encoding="utf-8-sig", errors="replace")
except FileNotFoundError:
return set()
except OSError as exc: # a refused read (sharing violation, AV scanner): unknown
raise RetiredUnknown(f"cannot read the retired paused-gateway list {target}: {exc}") from exc
try:
ids = json.loads(text).get("ids")
except (ValueError, AttributeError): # not JSON / not an object: malformed, like a non-list
ids = None
if not isinstance(ids, list):
return _quarantine_retired(path, target)
return {str(i) for i in ids}
def _quarantine_retired(path: Path, target: Path) -> set[str]:
"""A retired list that reads but does not parse. While a record or claim of this checkout
exists the list may be the only proof it is complete: unknown, fail closed. With none left it
guards nothing, and keeping it would abort every Windows update: set it aside and go on."""
if path.exists() or _claims(path):
raise RetiredUnknown(f"the retired paused-gateway list {target} is malformed")
corrupt = target.with_suffix(".corrupt")
try:
os.replace(target, corrupt)
except OSError as exc:
raise RetiredUnknown(f"cannot set aside the malformed retired paused-gateway list {target}: {exc}") from exc
print(f" ⚠ Set aside a malformed paused-gateway history (no paused set refers to it): {corrupt}",
file=sys.stderr)
return set()
def _retire(path: Path, carriers: list[tuple[Path, dict]]) -> None:
"""Complete the obligations *carriers* hold (call under the mutex): their ids (and every id
they absorbed) go on the durable retired list FIRST; a failed read or write raises and deletes
nothing. Then the files go; one that cannot be deleted is a redundant copy from here on."""
ids = {str(i) for _src, body in carriers
for i in [body["token"].get("pause_id"), *(body["token"].get("absorbed") or [])] if i}
retired = _retired(path)
if not ids <= retired:
_atomic_write(_retired_path(path), {"schema": 1, "ids": sorted(retired | ids)})
for src, _body in carriers:
with suppress(OSError):
src.unlink()
def _holder(src: Path, body: dict) -> dict:
"""Who holds *src*: a record's owner, a claim's claimer (published with the claim itself)."""
held_by = body.get("owner") if src.suffix == ".json" else body.get("claimer")
return held_by if isinstance(held_by, dict) else UNOWNED
def _files(path: Path) -> list[tuple[Path, dict]]:
"""This checkout's record and claims; one that cannot be read raises :class:`RecordUnknown`."""
found = []
for src in (path, *_claims(path)):
body = read(src)
if body is not None and body.get("install_root") == str(install_root()):
found.append((src, body))
return found
def _redundant(found: list[tuple[Path, dict]], held: set[Path], retired: set[str] = frozenset()) -> set[Path]:
"""Files that only copy an obligation another file carries or that was completed: a source an
update absorbed but died before retiring, one of two same-id files a claim transfer left (the
furthest-progressed copy is kept), a retired id's leftover. Never a held file."""
carriers: dict[str, Path] = {}
for src, body in found:
for oid in body["token"].get("absorbed") or []:
carriers.setdefault(str(oid), src)
redundant: set[Path] = set()
by_id: dict[str, list[Path]] = {}
for src, body in found:
oid = str(body["token"].get("pause_id") or "")
if not oid:
continue
if oid in retired or (oid in carriers and carriers[oid] != src):
redundant.add(src)
else:
by_id.setdefault(oid, []).append(src)
rev = {src: int(body.get("rev") or 0) for src, body in found}
for copies in by_id.values():
keep = next((s for s in copies if s in held), max(copies, key=lambda s: rev[s]))
redundant.update(s for s in copies if s != keep)
return redundant - held
def _survey(path: Path) -> tuple[list[tuple[Path, dict]], set[Path], set[Path]]:
found = _files(path)
held = {src for src, body in found if identity_is_live(_holder(src, body))}
return found, held, _redundant(found, held, _retired(path))
def orphans(path: Path | None = None) -> list[tuple[Path, dict]]:
"""``(file, body)`` for every obligation of this checkout whose holder is dead — one file per
obligation id; empty while another update is live (it adopts them itself). Surveyed under the
mutex (a mutator replaces the retired list meanwhile); raises when the list cannot be read."""
path = path or record_path()
if not path.exists() and not _claims(path):
return []
with _mutex():
found, held, redundant = _survey(path)
found = [(src, body) for src, body in found if src not in held and src not in redundant]
if not found or _live_update_elsewhere():
return []
return found
def retire_redundant(path: Path | None = None) -> None:
"""Delete copies of obligations another file already carries (crash leftovers of a transfer)."""
path = path or record_path()
if not path.exists() and not _claims(path) and not _retired_path(path).exists():
return
with suppress(RecordBusy), _mutex():
_found, _held, redundant = _survey(path)
for src in redundant:
with suppress(OSError):
src.unlink()
_prune_retired(path)
def _prune_retired(path: Path) -> None:
"""Forget retired ids no file carries any more (a carrier that cannot be read raises first)."""
retired = _retired(path)
bodies = [body for body in map(read, (path, *_claims(path))) if body is not None]
if not retired:
return
carried = {str(b["token"].get("pause_id")) for b in bodies}
keep = retired & carried
with suppress(OSError):
if keep:
_atomic_write(_retired_path(path), {"schema": 1, "ids": sorted(keep)})
else:
_retired_path(path).unlink()
def claim(src: Path) -> tuple[Path, dict] | None:
"""Take *src* (an orphaned record or a dead claimer's claim) for this process; ``None`` when a
concurrent launch won it or holds the mutex. The claim is published with our identity inside,
then the source is retired; a crash between leaves a same-id copy :func:`_redundant` drops."""
path = record_path()
mine = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.claim")
try:
with _mutex():
body = read(src)
if body is None or body.get("install_root") != str(install_root()):
return None
_found, held, redundant = _survey(path)
if src in held or src in redundant or src not in {s for s, _ in _found}:
return None
body["claimer"] = identity()
body["rev"] = int(body.get("rev") or 0) + 1 # a copy left behind is never the newer one
_atomic_write(mine, body)
with suppress(OSError):
src.unlink()
except OSError:
return None
return mine, body
def adopt_orphans() -> tuple[dict | None, list[Path]]:
"""For ``hermes update``: claim every orphaned pause and merge their sets. The caller records the
merged set durably (its ``absorbed`` ids name the claims), then :func:`release_claims`."""
retire_redundant()
adopted, claims = None, []
for src, _body in orphans():
won = claim(src)
if won is None:
continue
claims.append(won[0])
with _mutex():
body = read(won[0]) or won[1]
adopted = merge_into(adopted, drop_never_stopped(dict(body["token"])))
return adopted, claims
def release_claims(claims: list[Path]) -> None:
with suppress(RecordBusy), _mutex():
for path in claims:
with suppress(OSError):
path.unlink()
def record_pause(token: dict, adopted: dict | None, claims: list[Path]) -> dict:
"""Merge the adopted set, stamp the tree and persist under this process BEFORE the first stop.
Publish then retire: a crash between leaves claims whose ids the record lists as absorbed."""
if adopted is not None:
merge_into(token, adopted)
stamp_tree(token)
token.setdefault("stop_requested", [])
write(token, owner=identity())
release_claims(claims)
return token
_CARRIED = ("pause_id", "baselines", "identities", "stop_requested", "stop_sent", "stopper_pid",
"stop_markers", "absorbed")
def finish_pause(token: dict, intended: dict, adopted: dict | None) -> dict:
"""Carry the recorded pause identity onto the final token and mirror it to disk."""
if adopted is not None:
merge_into(token, adopted)
token.update({key: intended[key] for key in _CARRIED if key in intended})
sync(token)
return token
def abandon_pause(intended: dict, adopted: dict | None) -> None:
"""This run's own stops were rolled back in-line: only an adopted orphan set is still owed,
and it goes back on disk unowned for the next launch."""
if adopted is None:
discharge(intended)
return
carried = {key: intended[key] for key in ("pause_id", "baselines", "absorbed") if key in intended}
write({**adopted, **carried}, owner=UNOWNED)
def _live_pids(token: dict) -> set[str]:
return {str(pid) for pid, ct in (token.get("identities") or {}).items()
if identity_is_live({"pid": pid, "ct": ct})}
def _without(token: dict, pids: set[str]) -> dict:
token["profiles"] = {p: pid for p, pid in (token.get("profiles") or {}).items() if str(pid) not in pids}
token["unmapped"] = [u for u in (token.get("unmapped") or []) if str(u.get("pid")) not in pids]
if "posix_units" in token: # supervised POSIX entries (update_cmd_posix_pause) are judged like the rest
token["posix_units"] = [u for u in token["posix_units"] or [] if str(u.get("pid")) not in pids]
return token
def _request_on_disk(token: dict, pid: str) -> bool | None:
"""The planned-stop marker this update's stopper wrote for *pid* is still on disk (the gateway's
watcher has not consumed it yet): the request was issued even if the updater died before
:func:`mark_stop_sent` — or the gateway's receipt of accepting it (:func:`mark_stop_accepted`).
A marker naming another stopper (a user's ``hermes gateway stop``) is not. ``None``: one of them
exists but cannot be read or parsed — the stop may have been accepted, so it is unknown."""
path = (token.get("stop_markers") or {}).get(str(pid))
if not path:
return False
from gateway.status import _PLANNED_STOP_MARKER_TTL_S
# An unconsumed request expires; the receipt of an accepted one is evidence for the whole drain
# (only the accepting incarnation is ever judged: a later one is not this entry's live process).
verdicts = (_names_request(token, pid, Path(path), _PLANNED_STOP_MARKER_TTL_S),
_names_request(token, pid, _accepted_path(Path(path)), None))
return True if True in verdicts else None if None in verdicts else False
def _names_request(token: dict, pid: str, path: Path, ttl_s: int | None) -> bool | None:
"""Does *path* hold this stopper's request for *pid*'s incarnation? Only an absent file or a
valid one naming something else is ``False``; refused (a sharing violation, an AV scanner) or
malformed is ``None``, unknown."""
from gateway.status import _marker_is_stale, get_process_start_time
try:
marker = json.loads(path.read_text(encoding="utf-8-sig"))
target, stopper = int(marker["target_pid"]), int(marker["stopper_pid"])
except FileNotFoundError:
return False
except (OSError, ValueError, TypeError, KeyError):
return None
if (target != int(pid) or stopper != int(token.get("stopper_pid") or 0)
or (ttl_s is not None and not marker.get("accepted")
and _marker_is_stale(marker.get("written_at") or "", ttl_s))):
return False
expected, actual = marker.get("target_start_time"), get_process_start_time(int(pid))
# Match the consumer's optional birth fingerprint, including unavailable clocks.
return None in (expected, actual) or expected == actual
def _asked(token: dict) -> tuple[set[str], set[str]]:
"""``(asked, unknown)``: pids this update actually asked to stop (recorded as sent, or whose
request is still on disk), and pids whose request evidence exists but cannot be read."""
sent = {str(p) for p in token.get("stop_sent") or []}
verdicts = {str(p): _request_on_disk(token, str(p)) for p in token.get("stop_requested") or [] if str(p) not in sent}
return sent | {p for p, v in verdicts.items() if v}, {p for p, v in verdicts.items() if v is None}
def drop_never_stopped(token: dict) -> dict:
"""Drop entries whose recorded process is the same live incarnation AND was never actually asked
to stop (the update died before issuing its request, even with the intent on disk): it is still
serving, and a later exit — a user's ``hermes gateway stop`` included — is not this update's to
undo. One it asked may be draining: it keeps its restart debt (:func:`split_draining`). The
evidence is resolved here, once, into ``stop_sent`` (a merge carries it under another stopper).
Evidence that cannot be read keeps the entry too (it may be draining) but resolves nothing: it is
judged again next time. No stop record at all (a set from before stop tracking): nothing can be
told apart."""
if token.get("stop_requested") is None:
return token
asked, unknown = _asked(token)
token["stop_sent"] = sorted(asked)
return _without(token, _live_pids(token) - asked - unknown)
def split_draining(token: dict) -> dict:
"""Remove and return ``{"profiles", "unmapped", "posix_units"}`` whose recorded process is still
running: it was asked to stop and has not exited yet, so it can only be restarted once it has."""
live = _live_pids(token)
draining = {"profiles": {p: pid for p, pid in (token.get("profiles") or {}).items() if str(pid) in live},
"unmapped": [u for u in (token.get("unmapped") or []) if str(u.get("pid")) in live],
"posix_units": [u for u in (token.get("posix_units") or []) if str(u.get("pid")) in live]}
_without(token, live)
return draining
def merge_into(token: dict | None, adopted: dict) -> dict:
"""Fold an orphaned pause into this update's token so its resume brings both sets back; its
obligation id (and every id it absorbed) is recorded so a leftover copy is never resumed twice."""
token = token if token is not None else {"resume_needed": True, "profiles": {}, "unmapped_pids": [], "unmapped": []}
token["resume_needed"] = True
# Every pause keeps its own tree evidence: the adopted baseline stops this run's stamp from
# certifying what a killed update left half-written as "dirty before the pause", and this
# run's own baseline still guards the HEAD a killed update moved to.
baselines = token.setdefault("baselines", [])
baselines.extend(b for b in adopted.get("baselines") or [] if b not in baselines)
absorbed = token.setdefault("absorbed", [])
absorbed.extend(i for i in [adopted.get("pause_id"), *(adopted.get("absorbed") or [])] if i and i not in absorbed)
profiles = token.setdefault("profiles", {})
for name, pid in (adopted.get("profiles") or {}).items():
profiles.setdefault(name, pid)
unmapped = token.setdefault("unmapped", [])
unmapped.extend(u for u in adopted.get("unmapped") or [] if u not in unmapped)
identities = token.setdefault("identities", {})
for pid, ct in (adopted.get("identities") or {}).items():
identities.setdefault(pid, ct)
if "stop_requested" in adopted or "stop_requested" in token:
requested, sent = adopted.get("stop_requested"), adopted.get("stop_sent")
if requested is None: # a set with no stop record keeps every live entry: all count as asked
requested = sent = list(adopted.get("identities") or {})
token["stop_requested"] = sorted({*map(str, token.get("stop_requested") or []), *map(str, requested)})
token["stop_sent"] = sorted({*map(str, token.get("stop_sent") or []), *map(str, sent or [])})
cold = token.setdefault("cold_start_profiles", {})
for name, generation in (adopted.get("cold_start_profiles") or {}).items():
cold.setdefault(name, generation)
if adopted.get("cold_start_if_installed"):
token["cold_start_if_installed"] = True
if "attested_generation" in adopted:
token.setdefault("attested_generation", adopted["attested_generation"])
if adopted.get("platform"): # a POSIX set (update_cmd_posix_pause): its supervised units ride along
token["platform"] = adopted["platform"]
units = token.setdefault("posix_units", [])
units.extend(u for u in adopted.get("posix_units") or [] if u not in units)
services = token.setdefault("services", [])
services.extend(s for s in adopted.get("services") or [] if s not in services)
if services:
token.setdefault("expected_services", []).extend(s for s in services if s not in token["expected_services"])
token.setdefault("restarted_services", [])
token.setdefault("service_profiles", {}).update(adopted.get("service_profiles") or {})
return token
def _has_work(token: dict) -> bool:
return bool(token.get("profiles") or any(u.get("argv") for u in token.get("unmapped") or [])
or token.get("services") or token.get("posix_units") or token.get("cold_start_if_installed") or token.get("cold_start_profiles"))
def _resume_claimed(claim_path: Path, body: dict) -> None:
"""Resume under the checkout lock, so no update mutates the tree between the gate and the
verified start; an update that took the checkout after our claim gets the set back untouched."""
from hermes_cli.update_lock import UpdateLock
fence = UpdateLock()
if not fence.acquire_checkout(install_root()):
with suppress(OSError), _mutex():
_atomic_write(claim_path, {**(read(claim_path) or body), "claimer": UNOWNED})
return
try:
_resume_fenced(claim_path, body)
finally:
fence.release()
def _resume_fenced(claim_path: Path, body: dict) -> None:
with _mutex():
# A live consumer may have checkpointed after claim() returned its snapshot.
body = read(claim_path) or body
token = drop_never_stopped(dict(body["token"]))
draining = split_draining(token)
_hold_backing_off(token, draining)
attempted = _relaunch_keys(token)
token.update(resume_needed=True, recovery=True)
try:
if _has_work(token):
print("→ Restarting gateway(s) paused by an interrupted `hermes update`...", file=sys.stderr)
from hermes_cli.update_cmd_windows import _resume_windows_gateways_after_update
# Whatever command this launch runs owns stdout (``--json`` output, a Desktop pipe).
with redirect_stdout(sys.stderr):
_resume_windows_gateways_after_update(token)
else:
token["resume_needed"] = False
except Exception as exc: # health: allow BLE001 -- recovery boundary: any resume error is reported and the claim handed back (finally), never raised into the launch
print(f" ⚠ Could not restart every paused gateway: {exc}. Run `hermes update` or "
"`hermes gateway start`.", file=sys.stderr)
finally:
_back_off_unready(token, attempted, draining)
_hand_back(claim_path, body, token, draining)
# A recovering launch runs before every command (chat, doctor, a Desktop-spawned serve) and waits
# for each relaunched gateway to become ready. One that does not is re-launched only after this
# backoff (``relaunch_retry``), so an interactive command pays that wait at most once per window;
# the debt itself stays owed, and ``hermes update`` (which drops the backoff) retries at once.
_RELAUNCH_RETRY_BASE_S = 60.0
_RELAUNCH_RETRY_MAX_S = 3600.0
def _relaunch_keys(token: dict) -> set[str]:
return ({f"profile:{name}" for name in token.get("profiles") or {}}
| {f"unmapped:{u.get('pid')}" for u in token.get("unmapped") or [] if u.get("argv")}
| {f"unit:{u.get('unit') or u.get('label')}" for u in token.get("posix_units") or []})
def _hold_backing_off(token: dict, held: dict) -> None:
"""Move the profile/unmapped/supervised entries still inside their relaunch backoff from *token*
into *held* (beside the draining ones): owed, not relaunched by this launch."""
now = time.time()
waiting = {key for key, state in (token.get("relaunch_retry") or {}).items() if now < float(state.get("next_at") or 0)}
held["profiles"].update({n: p for n, p in token["profiles"].items() if f"profile:{n}" in waiting})
held["unmapped"].extend(u for u in token["unmapped"] if u.get("argv") and f"unmapped:{u.get('pid')}" in waiting)
token["profiles"] = {n: p for n, p in token["profiles"].items() if f"profile:{n}" not in waiting}
token["unmapped"] = [u for u in token["unmapped"] if u not in held["unmapped"]]
if units := token.get("posix_units"):
held["posix_units"].extend(u for u in units if f"unit:{u.get('unit') or u.get('label')}" in waiting)
token["posix_units"] = [u for u in units if u not in held["posix_units"]]
def _back_off_unready(token: dict, attempted: set[str], held: dict) -> None:
"""Start or extend the backoff of each attempted entry the resume left owed (not ready, not
launched); forget it for one that came back. A tree-gate deferral attempted nothing."""
retry = dict(token.get("relaunch_retry") or {})
if not token.get("resume_deferred"):
unready = _relaunch_keys(token)
for key in attempted:
if key not in unready:
retry.pop(key, None)
continue
attempts = int((retry.get(key) or {}).get("attempts") or 0) + 1
retry[key] = {"attempts": attempts, "next_at": time.time() + min(
_RELAUNCH_RETRY_BASE_S * 2 ** min(attempts - 1, 6), _RELAUNCH_RETRY_MAX_S)}
owed = _relaunch_keys(token) | _relaunch_keys(held)
token["relaunch_retry"] = {key: state for key, state in retry.items() if key in owed}
def _hand_back(claim_path: Path, body: dict, token: dict, draining: dict) -> None:
"""Retire the claim when nothing is owed; else hand it back unowned for the next launch — this
launch may live for hours (a chat) — which retries at once, except a relaunch that did not
become ready (its backoff, :func:`_back_off_unready`). A draining process stays owed until it
has exited and been restarted. A failed rewrite leaves our claimer line, dead once we exit."""
token["profiles"] = {**(token.get("profiles") or {}), **draining["profiles"]}
token["unmapped"] = [*(token.get("unmapped") or []), *draining["unmapped"]]
if draining.get("posix_units"):
token["posix_units"] = [*(token.get("posix_units") or []), *draining["posix_units"]]
owed = bool(token.get("resume_needed") or token.get("resume_deferred") or draining["profiles"]
or draining["unmapped"] or draining.get("posix_units"))
with suppress(OSError), _mutex():
if owed:
kept = {key: value for key, value in token.items() if key not in ("recovery", "resume_deferred")}
_atomic_write(claim_path, {**body, "token": {**kept, "resume_needed": True}, "claimer": UNOWNED})
else:
_retire(record_path(), [(claim_path, read(claim_path) or body)])
def recover(argv: list[str] | None = None) -> None:
"""Start-of-run recovery: resume every orphaned pause. Never raises."""
from hermes_cli._parser import command_argv
command = command_argv(list(sys.argv[1:] if argv is None else argv))
if command[:1] == ["update"] or command[:2] == ["gateway", "run"]:
return # the update adopts it itself; a booting gateway must not block on its siblings
try:
retire_redundant()
for src, _body in orphans():
won = claim(src)
if won is not None:
_resume_claimed(*won)
except Exception as exc: # health: allow BLE001 -- never brick a launch on recovery; the record stays for the next one
print(f" ⚠ Paused-gateway recovery skipped: {exc}", file=sys.stderr)