Keep downloads bound to their remote representation and publish through atomic destination-local staging. Serialize shared partial ownership. Keep explicit CA trust scoped to provider probes. Preserve checkpoint history and edited files, validate all profile inputs before dependency publication, and separate data removal from installed runtime ownership. Exclude machine-specific PM state from portable transfers. Keep plugin files and nested skill tools intact. Preserve native test isolation. Focused native Windows receipts cover the individual repairs and their integration. This commit does not claim a full-suite or release build.
150 lines
7.3 KiB
Python
150 lines
7.3 KiB
Python
"""Home-scoped runtime coordination for an explicitly confirmed data wipe."""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack, contextmanager
|
|
from pathlib import Path
|
|
import time
|
|
|
|
|
|
_OWNER_STOP_HINTS = {
|
|
"systemd": "stop its systemd unit with systemctl before retrying",
|
|
"launchd": "stop its LaunchAgent with launchctl before retrying",
|
|
"desktop": "quit the owning desktop app before retrying",
|
|
"external": "stop the gateway through its external supervisor before retrying",
|
|
}
|
|
|
|
|
|
def _drain_manual_gateway(home: Path) -> None:
|
|
from gateway.control_socket import identify_gateway, pause_gateway_for_update
|
|
from gateway.status import get_process_start_time, get_running_pid_identity_strict, _pid_exists
|
|
|
|
identity = identify_gateway(home)
|
|
if identity is None:
|
|
if get_running_pid_identity_strict(home / "gateway.pid") is not None:
|
|
raise RuntimeError(f"gateway has no usable control socket for {home}; stop it through its owner and retry")
|
|
return
|
|
if not identity.get("hermes_home") or Path(identity["hermes_home"]).resolve() != home:
|
|
raise RuntimeError(f"gateway control identity does not match {home}")
|
|
owner = identity.get("supervisor")
|
|
if owner != "manual":
|
|
hint = _OWNER_STOP_HINTS.get(owner, "stop it through its supervisor before retrying")
|
|
raise RuntimeError(f"{owner or 'unknown'} gateway still owns {home}; {hint}")
|
|
if identity.get("served_profiles"):
|
|
raise RuntimeError("gateway serves multiple profiles; stop it explicitly before deleting one profile's data")
|
|
pid = identity.get("pid")
|
|
start = identity.get("start_time")
|
|
if type(pid) is not int or pid <= 0 or start is None or get_process_start_time(pid) != start:
|
|
raise RuntimeError(f"gateway process identity is unverified for {home}")
|
|
response = pause_gateway_for_update(home)
|
|
if not response or response.get("pid") != identity.get("pid") or not (
|
|
response.get("pausing") or response.get("already_stopping")
|
|
):
|
|
raise RuntimeError(f"gateway refused to drain for {home}")
|
|
deadline = time.monotonic() + min(120.0, max(5.0, float(response.get("drain_timeout", 30.0)) + 5.0))
|
|
while time.monotonic() < deadline:
|
|
current = get_process_start_time(pid)
|
|
original_gone = not _pid_exists(pid) or (current is not None and current != start)
|
|
if original_gone and identify_gateway(home) is None and get_running_pid_identity_strict(home / "gateway.pid") is None:
|
|
return
|
|
time.sleep(0.05)
|
|
raise RuntimeError(f"gateway did not exit for {home}; no data removed")
|
|
|
|
|
|
def _refuse_backend_writers(home: Path) -> None:
|
|
from hermes_constants import get_default_hermes_root
|
|
from hermes_cli.process_identity import LEDGER_FILENAME, _pid_alive_matches, _read_ledger
|
|
|
|
root = get_default_hermes_root(home=home).resolve()
|
|
rows = _read_ledger(root / LEDGER_FILENAME)
|
|
if rows is None:
|
|
raise RuntimeError(f"cannot read backend ownership: {root / LEDGER_FILENAME}")
|
|
for row in rows:
|
|
if row.get("purpose") not in {"serve", "dashboard"}:
|
|
continue
|
|
# The recorded profile is the initial UI selection, not the backend's
|
|
# write scope: it can serve any profile under this machine root.
|
|
pid = row.get("pid")
|
|
if type(pid) is not int or pid <= 0:
|
|
raise RuntimeError("backend ownership contains an invalid PID")
|
|
if _pid_alive_matches(pid, row.get("create_time")) is not False:
|
|
raise RuntimeError(f"backend PID {pid} still owns {home}; close its desktop/dashboard before retrying")
|
|
|
|
|
|
def _refuse_cron_writers(home: Path) -> None:
|
|
from contextlib import closing
|
|
import sqlite3
|
|
from gateway.status import get_process_start_time, _pid_exists
|
|
|
|
path = home / "cron" / "executions.db"
|
|
try:
|
|
path.stat()
|
|
except FileNotFoundError:
|
|
return
|
|
# Importing cron initializes scheduler/config defaults. A deletion probe
|
|
# must only read the existing ledger, not recreate the data being removed.
|
|
try:
|
|
with closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.25)) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute("SELECT * FROM executions WHERE status IN ('claimed','running')").fetchall()
|
|
except sqlite3.Error as exc:
|
|
raise RuntimeError(f"cannot inspect cron owners in {path}: {exc}") from exc
|
|
for raw in rows:
|
|
row = dict(raw)
|
|
pid = row["pid"]
|
|
if row.get("handoff_pending"):
|
|
raise RuntimeError(f"cron execution {row['id']} is being handed to a worker; wait for it to finish")
|
|
if not _pid_exists(pid):
|
|
continue
|
|
current = get_process_start_time(pid)
|
|
recorded = row.get("process_started_at")
|
|
if current is None or recorded is None or current == recorded:
|
|
raise RuntimeError(f"cron execution {row['id']} still owns {home} (PID {pid}); stop it before retrying")
|
|
|
|
|
|
def _refuse_multiplexer(home: Path) -> None:
|
|
from hermes_constants import get_default_hermes_root
|
|
from gateway.control_socket import identify_gateway
|
|
from gateway.status import get_running_pid_identity_strict
|
|
|
|
root = get_default_hermes_root(home=home).resolve()
|
|
if home == root:
|
|
return
|
|
identity = identify_gateway(root)
|
|
if identity is not None:
|
|
if home.name in identity.get("served_profiles", []):
|
|
raise RuntimeError(f"the default gateway serves {home}; stop the multiplexer explicitly before retrying")
|
|
elif get_running_pid_identity_strict(root / "gateway.pid") is not None:
|
|
raise RuntimeError("the default gateway's profile scope is unverified; stop it explicitly before retrying")
|
|
|
|
|
|
@contextmanager
|
|
def quiescent_home(home: Path):
|
|
"""Hold existing writer locks through deletion; do not remove their inodes."""
|
|
from gateway.status import _release_file_lock, _try_acquire_file_lock
|
|
from hermes_cli.active_sessions import _FileLock, _prune_dead, _read_entries
|
|
from hermes_cli.runtime_state import _lock
|
|
from tools.checkpoint_pruning import store_lock
|
|
|
|
home = home.resolve()
|
|
_refuse_backend_writers(home)
|
|
_refuse_multiplexer(home)
|
|
_drain_manual_gateway(home)
|
|
with ExitStack() as stack:
|
|
stack.enter_context(store_lock(home / "checkpoints"))
|
|
gateway_lock = stack.enter_context((home / "gateway.lock").open("a+", encoding="utf-8"))
|
|
if not _try_acquire_file_lock(gateway_lock):
|
|
raise RuntimeError(f"gateway is still active for {home}")
|
|
stack.callback(_release_file_lock, gateway_lock)
|
|
stack.enter_context(_FileLock(home / "runtime" / "active_sessions.lock"))
|
|
sessions = _prune_dead(_read_entries(home / "runtime" / "active_sessions.json", strict=True), strict=True)
|
|
if sessions:
|
|
pids = sorted({entry["pid"] for entry in sessions})
|
|
raise RuntimeError(f"live chat sessions still own {home} (PIDs {pids}); close them before retrying")
|
|
for path in (home / ".backup.lock", home / "cron" / ".tick.lock"):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = stack.enter_context(path.open("a+b"))
|
|
if not _lock(handle.fileno(), wait=False):
|
|
raise RuntimeError(f"an active writer still owns {path}")
|
|
_refuse_cron_writers(home)
|
|
yield
|