mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
An elevated Hermes (install, `hermes update` as admin, a gateway started from an admin prompt) creating a dir with mode=0o700 gets CPython's protected SYSTEM + Administrators + OWNER RIGHTS DACL, owned by Administrators. The same user's non-elevated Desktop/gateway/CLI then gets WinError 5 inside it (#139845). - hermes_dir_acl.make_user_dir: Path.mkdir that, only when the process is elevated on Windows, adds an inheriting full-control ACE for the token's user SID to every dir it created and to an existing leaf (repairs a dir an earlier elevated run left). No-op off Windows / unelevated. Routed through it: the gateway lock dir (scoped token locks, host rendezvous, update host obligation), bot mailbox, gateway.lock homes, pending_messages, media/attachment roots, process logs, spill dirs, vault, outbox, backups, home skeleton and mkdir_under_hermes_home (logs). - W2: acquire_scoped_lock raises ScopedLockDirUnusable when this token may not use gateway-locks (or read a stale lock in it), logged WARNING once per dir with the repair (`icacls DIR /reset /t`). _acquire_platform_lock parks the adapter as non-retryable `lock_dir_unusable` naming the dir and the fix, instead of a raw PermissionError that Telegram retried forever. - Builds on @KoNit-K's update-time ACL repair (#139870), which re-grants the whole home after an elevated update. No Windows host was available: the ACL behavior is covered by tests/test_hermes_dir_acl_windows.py (Windows CI) and needs a live retest. Refs #134889 (W8, W2) Refs #139845 Refs #140337
481 lines
22 KiB
Python
481 lines
22 KiB
Python
"""Host-scoped update→restart obligation for ``hermes update``.
|
|
|
|
Multiplex-only (Teknium ruling): exactly ONE ``hermes gateway run`` per host serves every
|
|
profile, so "this pull still owes the fleet a restart" is a property of the HOST, not of one
|
|
profile's ``HERMES_HOME``. The legacy ``$HERMES_HOME/fleet_restart_pending`` marker was
|
|
per-home: ``hermes -p coder update`` armed and cleared coder's copy while restarting the
|
|
SHARED process, and every other profile's CLI could neither see nor discharge that obligation
|
|
— it simply armed its own and re-killed the same host process.
|
|
|
|
The record therefore lives beside the host rendezvous record, in
|
|
:func:`gateway.host_rendezvous.host_state_dir` (``$HERMES_GATEWAY_LOCK_DIR`` else
|
|
``$XDG_STATE_HOME/hermes/gateway-locks``) — the one cross-profile, per-OS-user state root the
|
|
tree already has. It is written once per host and installation (keyed by the checkout), read by
|
|
every profile's CLI, and cleared once.
|
|
|
|
The same "one host process, not one per profile" identity is what
|
|
:func:`collapse_units_to_host_processes` applies to enumerated systemd units: leftover
|
|
per-profile ``hermes-gateway-<p>.service`` units on a multiplexed host all point at the same
|
|
live ``MainPID``, so restarting each one restarts the host process N times.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Optional
|
|
from hermes_dir_acl import make_user_dir
|
|
|
|
logger = logging.getLogger("hermes_cli.update_cmd")
|
|
|
|
#: The record before it carried an install key, beside ``host-gateway.json`` / ``host-serve.json``.
|
|
#: Still read, and cleared by a discharge that judged it, so a debt an older release armed survives
|
|
#: the upgrade; it names no install, so it keeps its old meaning: owed by whichever install reads it.
|
|
HOST_OBLIGATION_NAME = "host-update-restart.json"
|
|
|
|
#: The per-``HERMES_HOME`` breadcrumb an unwritable host record falls back to; every reader honours it.
|
|
PROFILE_MARKER_NAME = "fleet_restart_pending"
|
|
|
|
_RECORD_VERSION = 1
|
|
|
|
|
|
def host_obligation_path() -> Path:
|
|
"""This installation's host obligation record.
|
|
|
|
Keyed by the checkout this module runs from: every profile of one install arms, reads and
|
|
clears the same record, while a second install of the same OS user (another checkout, a ZIP
|
|
install) owes its own, so its completed restart never erases this install's debt (review S3).
|
|
"""
|
|
# Not ``gateway.host_rendezvous.host_state_dir``: ``gateway.status`` imports ``utils`` -> ruamel,
|
|
# absent from the historical interpreter an old updater's takeover arms this record in; the
|
|
# recovery module's stdlib copy of the rule is drift-tested against the gateway resolver.
|
|
from hermes_cli.update_restart_recovery import _host_state_dir
|
|
|
|
install = os.path.normcase(str(Path(__file__).resolve().parents[1]))
|
|
key = hashlib.sha256(install.encode("utf-8")).hexdigest()[:16]
|
|
return Path(_host_state_dir()) / f"{Path(HOST_OBLIGATION_NAME).stem}-{key}.json"
|
|
|
|
|
|
def _owed_path() -> Path:
|
|
"""The record a reader honours: this install's, else an older release's unkeyed one."""
|
|
path = host_obligation_path()
|
|
legacy = path.with_name(HOST_OBLIGATION_NAME)
|
|
return legacy if not os.path.exists(path) and os.path.exists(legacy) else path # never raises
|
|
|
|
|
|
def _write_record(path: Path, record: dict) -> None:
|
|
"""``utils.atomic_json_write(path, record, mode=0o600)`` in stdlib only (see above)."""
|
|
replace_bytes(path, json.dumps(record, indent=2, ensure_ascii=False).encode("utf-8"))
|
|
|
|
|
|
def read_host_obligation() -> Optional[dict]:
|
|
"""The published obligation record, or ``None`` when absent/corrupt/foreign-versioned."""
|
|
return _read_record(_owed_path())
|
|
|
|
|
|
def _read_record(path: Path) -> Optional[dict]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, UnicodeDecodeError, ValueError):
|
|
return None
|
|
if not isinstance(payload, dict) or payload.get("version") != _RECORD_VERSION:
|
|
return None
|
|
return payload
|
|
|
|
|
|
def host_obligation_present() -> bool:
|
|
"""True when the record FILE exists, parseable or not.
|
|
|
|
Fail-closed: a corrupt record is an obligation whose terms are unknown, never a discharged
|
|
one — the restart is still owed and the reader falls back to "no recorded inventory".
|
|
"""
|
|
try:
|
|
return _owed_path().is_file()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def amend_host_obligation(**fields: Any) -> None:
|
|
"""Merge ``fields`` into the armed record (test/diagnostic surface). Never raises."""
|
|
_update_record(lambda record: record.update(fields), "amend")
|
|
|
|
|
|
def write_host_obligation(
|
|
*, expected_sha: str = "", runtimes: Optional[list] = None, profile: str = "", owner: str = ""
|
|
) -> bool:
|
|
"""Arm the host obligation. True when it was written. Never raises.
|
|
|
|
Re-arming from a second profile for the SAME pulled SHA keeps the existing record (and its
|
|
``restarted`` proof) instead of resetting it: the host owes one restart, not one per profile.
|
|
``owner`` (an update run's commit-point token) joins the record's ``owners``: a run that
|
|
fails before its move hands back only its own stake (``release_host_obligation``), never
|
|
another run's debt for the same SHA. The first owner also stores what it found there
|
|
(``found``), which the last owner to leave puts back; an owner retargeting to another SHA
|
|
keeps that baseline (``_baseline_for``).
|
|
"""
|
|
path = host_obligation_path()
|
|
try:
|
|
with _record_mutex(path):
|
|
return _write_locked(path, expected_sha=expected_sha, runtimes=runtimes, profile=profile, owner=owner)
|
|
except OSError as exc: # the mutex busy or unopenable: the caller falls back to the per-home marker
|
|
logger.debug("Could not lock the host update-restart obligation: %s", exc)
|
|
return False
|
|
|
|
|
|
def arm_host_obligation(
|
|
marker: Path, *, expected_sha: str = "", runtimes: Optional[list] = None, profile: str = "", owner: str = ""
|
|
) -> bool:
|
|
"""Arm the host record, else ``marker`` (the arming profile's per-home breadcrumb). Never raises.
|
|
|
|
The one fallback for every arm (``hermes update``'s commit point, a historical updater's
|
|
takeover). An unwritable host state dir (``HERMES_GATEWAY_LOCK_DIR`` on a read-only mount, a
|
|
container UID that does not own ``$HOME``) must never disarm the obligation (#117275), but the
|
|
marker is visible to the arming profile only: it settles the debt only on an install PROVEN to
|
|
have no other profile, and an unreadable profile roster proves nothing (review S2 residual).
|
|
Otherwise the marker is still left for this profile, the gap is said out loud, and the result
|
|
is False: the commit point refuses to move; a takeover, whose tree already moved, keeps the warning.
|
|
"""
|
|
if write_host_obligation(expected_sha=expected_sha, runtimes=runtimes, profile=profile, owner=owner):
|
|
return True
|
|
lines = [f"started={time.time()}", f"pid={os.getpid()}"] + ([f"expected_sha={expected_sha}"] if expected_sha else [])
|
|
if runtimes is not None:
|
|
lines.append("inventory=" + json.dumps({"version": 1, "runtimes": runtimes}))
|
|
hidden = ""
|
|
try:
|
|
replace_bytes(marker, ("\n".join(lines) + "\n").encode("utf-8"))
|
|
except OSError as exc:
|
|
hidden = f" or {marker} ({exc})"
|
|
if not hidden:
|
|
try:
|
|
if _named_profiles_exist():
|
|
hidden = f" ({marker} hides it from the other profiles)"
|
|
except OSError as exc:
|
|
hidden = f" ({marker} may hide it from other profiles: the profile list is unreadable: {exc})"
|
|
if not hidden:
|
|
logger.warning("Host update-restart obligation (%s) is unwritable; armed the per-home marker %s instead.",
|
|
host_obligation_path(), marker)
|
|
return True
|
|
logger.error("Could not arm the update-restart obligation in %s%s; an interrupted update will not warn.",
|
|
host_obligation_path(), hidden)
|
|
# ASCII: a historical updater's takeover may print through a legacy console code page.
|
|
print(" Warning: could not record the pending gateway-restart obligation where every profile sees it "
|
|
"(state dir not writable) - restart gateways with `hermes gateway restart` if this update is "
|
|
"interrupted.", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def _named_profiles_exist() -> bool:
|
|
"""True when the install has a live named profile beside ``default``, read from a COMPLETE roster.
|
|
|
|
``profiles.list_profile_names`` suppresses enumeration errors and reads an unreadable roster as
|
|
"default only"; here any error listing the roster or reading an entry's identity raises instead.
|
|
Same identity rule as ``hermes_constants.named_profile_is_live`` (stdlib only: see above).
|
|
"""
|
|
from hermes_constants import _PROFILE_IDENTITY_MARKERS, PROFILE_ID_RE, get_default_hermes_root, profile_tombstone_path
|
|
|
|
try:
|
|
entries = list((get_default_hermes_root() / "profiles").iterdir())
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
return False
|
|
for home in entries:
|
|
if home.name == "default" or not PROFILE_ID_RE.match(home.name) or not stat.S_ISDIR(_mode(home, os.stat)):
|
|
continue
|
|
identity = any((mode := _mode(home / name, os.lstat)) and not stat.S_ISDIR(mode)
|
|
for name in _PROFILE_IDENTITY_MARKERS)
|
|
if identity and not _mode(profile_tombstone_path(home), os.lstat):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _mode(path: Path, probe: Callable[[Path], os.stat_result]) -> int:
|
|
"""``probe(path).st_mode``; 0 when absent. Any other error raises: unreadable is unknown, not absent."""
|
|
try:
|
|
return probe(path).st_mode
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
return 0
|
|
|
|
|
|
def _write_locked(path: Path, *, expected_sha: str, runtimes: Optional[list], profile: str, owner: str) -> bool:
|
|
existing = _read_record(path)
|
|
try:
|
|
found = _baseline_for(owner, existing, path) if owner else None
|
|
except OSError as exc: # unreadable is not absent: a guessed ``found`` would delete it later
|
|
logger.debug("Could not read the host update-restart obligation: %s", exc)
|
|
return False
|
|
if existing is not None and expected_sha and existing.get("expected_sha") == expected_sha:
|
|
# Same pull, second profile: the host owes ONE restart, so keep the standing record (and
|
|
# any proof that the restart already happened) rather than resetting it. A later arm that
|
|
# carries the owed inventory still upgrades it — an inventory-less record owes no set.
|
|
fields: dict[str, Any] = {}
|
|
owners = _owners(existing)
|
|
if owner and owner not in owners:
|
|
fields["owners"] = [*owners, owner]
|
|
if not owners:
|
|
fields["found"] = found
|
|
inventory = {"version": 1, "runtimes": runtimes}
|
|
if runtimes is not None and existing.get("inventory") != inventory:
|
|
fields["inventory"] = inventory
|
|
if not fields:
|
|
return True
|
|
try:
|
|
_write_record(path, {**existing, **fields})
|
|
except Exception as exc: # health: allow BLE001 -- never raises: the caller falls back to the per-home marker
|
|
logger.debug("Could not amend host update-restart obligation: %s", exc)
|
|
return False
|
|
return True
|
|
payload: dict[str, Any] = {
|
|
"version": _RECORD_VERSION,
|
|
"started": time.time(),
|
|
"pid": os.getpid(),
|
|
"armed_by_profile": profile or "",
|
|
"expected_sha": expected_sha or "",
|
|
}
|
|
if owner:
|
|
payload["owners"] = [owner]
|
|
payload["found"] = found
|
|
if runtimes is not None:
|
|
payload["inventory"] = {"version": 1, "runtimes": runtimes}
|
|
try:
|
|
make_user_dir(path.parent, parents=True, exist_ok=True)
|
|
_write_record(path, payload)
|
|
except Exception as exc: # health: allow BLE001 -- never raises: the caller falls back to the per-home marker
|
|
logger.debug("Could not write host update-restart obligation: %s", exc)
|
|
return False
|
|
return True
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _record_mutex(path: Path):
|
|
"""Serialize every read-judge-write of the record across processes (``update_lock.marker_mutex``).
|
|
|
|
The sidecar sits in a subdirectory: the state dir is the gateway lock dir, whose ``--replace``
|
|
cleanup unlinks every top-level ``*.lock``, and a deleted sidecar splits the lock in two.
|
|
"""
|
|
# One guard for every install's record (and the unkeyed one an older release still locks).
|
|
guard = path.parent / f".{Path(HOST_OBLIGATION_NAME).stem}.mutex"
|
|
make_user_dir(guard, parents=True, exist_ok=True)
|
|
with _marker_mutex()(guard / "record"):
|
|
yield
|
|
|
|
|
|
def _marker_mutex():
|
|
"""``update_lock.marker_mutex``. A historical updater imported ``update_lock`` before its pull and
|
|
then lazily imports this pulled module, so its in-memory copy predates the mutex: load the pulled
|
|
``update_lock`` (stdlib-only) beside it, which locks the same sidecar every current process does."""
|
|
import importlib.util
|
|
import sys
|
|
|
|
from hermes_cli import update_lock
|
|
|
|
if hasattr(update_lock, "marker_mutex"):
|
|
return update_lock.marker_mutex
|
|
name = "hermes_cli._update_lock_pulled"
|
|
pulled = sys.modules.get(name)
|
|
if pulled is None:
|
|
spec = importlib.util.spec_from_file_location(name, update_lock.__file__)
|
|
pulled = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = pulled # before exec: dataclasses resolve the module by name
|
|
spec.loader.exec_module(pulled)
|
|
return pulled.marker_mutex
|
|
|
|
|
|
def _owners(record: dict) -> list[str]:
|
|
owners = record.get("owners")
|
|
return [str(o) for o in owners] if isinstance(owners, list) else []
|
|
|
|
|
|
def _baseline_for(owner: str, existing: Optional[dict], path: Path) -> Optional[str]:
|
|
"""What ``owner``'s release must put back (base64; ``None`` = absent): the record as it stood
|
|
before ``owner``'s first stake in it (review R1).
|
|
|
|
A run re-arms with one owner as it retargets (CP0 branch, origin pull, upstream fork ff), so
|
|
"the record already has owners" never means "nothing was here": a new owner keeps the bytes
|
|
there now, other owners' stakes included; an owner already in the record carries the baseline
|
|
it saved then, or, when it shared the record with others, the shared record minus its own stake.
|
|
"""
|
|
owners = _owners(existing or {})
|
|
if owner not in owners:
|
|
return _found_field(path)
|
|
others = [o for o in owners if o != owner]
|
|
if not others:
|
|
return (existing or {}).get("found")
|
|
shared = json.dumps({**(existing or {}), "owners": others}, indent=2, ensure_ascii=False)
|
|
return base64.b64encode(shared.encode("utf-8")).decode("ascii")
|
|
|
|
|
|
def _found_field(path: Path) -> Optional[str]:
|
|
"""The record's current bytes (base64), ``None`` when absent: what a first owner puts back.
|
|
Any other read error raises: custody is never guessed."""
|
|
try:
|
|
return base64.b64encode(path.read_bytes()).decode("ascii")
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
|
|
def release_host_obligation(owner: str) -> None:
|
|
"""Hand back ``owner``'s stake in the record. When no other run still owes through it, the
|
|
record goes back to what its first owner found (``found``; absent = unlinked).
|
|
|
|
A record without ``owner`` was rewritten since (another run's newer pull): it is theirs and
|
|
stays. Raises OSError when the record cannot be rewritten (the caller keeps the debt armed).
|
|
Judged and rewritten under ``_record_mutex``, which every arm takes too, so no arm can land
|
|
between the look and the write and be undone by it (kshitijk4poor F22/N05).
|
|
"""
|
|
path = host_obligation_path()
|
|
if not owner or owner not in _owners(_read_record(path) or {}):
|
|
return # nothing of ours to hand back: no lock taken, no state dir created
|
|
with _record_mutex(path):
|
|
_release_locked(path, owner)
|
|
|
|
|
|
def _release_locked(path: Path, owner: str) -> None:
|
|
record = _read_record(path)
|
|
owners = _owners(record) if record is not None else []
|
|
if owner not in owners:
|
|
return # rewritten since the unlocked look (another run's arm): theirs now
|
|
remaining = [o for o in owners if o != owner]
|
|
if remaining:
|
|
_write_record(path, {**record, "owners": remaining})
|
|
return
|
|
try:
|
|
found = base64.b64decode(record["found"], validate=True) if record.get("found") is not None else None
|
|
except (TypeError, ValueError):
|
|
return # a damaged ``found`` cannot be put back: the debt stays armed, never guessed away
|
|
if found is None:
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
replace_bytes(path, found)
|
|
|
|
|
|
def replace_bytes(path: Path, data: bytes) -> None:
|
|
"""``data`` at ``path`` as one durable record: a fresh 0600 ``mkstemp`` file (never written
|
|
through a planted alias), fsynced, renamed over ``path``, then the rename itself made durable."""
|
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.stem}_", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(tmp)
|
|
raise
|
|
if os.name != "nt": # Windows opens no directory handle; NTFS journals the rename itself
|
|
directory = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
|
|
|
|
def clear_host_obligation() -> None:
|
|
"""Discharge the record this install's readers judged (``_owed_path``). Never raises.
|
|
|
|
That is this install's own record whenever it has one. An older release's unkeyed record names
|
|
no install: while this install has its own record, the unkeyed one may be another
|
|
installation's standing debt that nothing here judged, so it stays (review S3 residual). It goes
|
|
only when it was the record judged (this install armed none): it keeps its old meaning, owed by
|
|
whichever install reads it, and an upgrade's own interrupted debt must stay dischargeable.
|
|
"""
|
|
try:
|
|
_owed_path().unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
logger.debug("Could not clear host update-restart obligation: %s", exc)
|
|
|
|
|
|
def obligation_fields() -> Optional[dict[str, str]]:
|
|
"""The obligation in the legacy ``key=value`` field shape, or ``None`` when unarmed.
|
|
|
|
Keeps one parser for both sources: the fields a reader needs (``expected_sha``, the
|
|
serialized ``inventory``) are identical whether they came from the host record or from an
|
|
in-flight legacy per-home marker.
|
|
"""
|
|
record = read_host_obligation()
|
|
if record is None:
|
|
return None
|
|
fields = {"expected_sha": str(record.get("expected_sha") or "")}
|
|
inventory = record.get("inventory")
|
|
if inventory is not None:
|
|
fields["inventory"] = json.dumps(inventory)
|
|
return fields
|
|
|
|
|
|
def mark_host_restart_completed(sha: str) -> None:
|
|
"""Record that the host process was restarted onto ``sha``. Never raises."""
|
|
_update_record(
|
|
lambda record: record.update(restarted={"sha": sha or "", "pid": os.getpid(), "at": time.time()}),
|
|
"stamp restart completion on")
|
|
|
|
|
|
def _update_record(change: Callable[[dict], None], what: str) -> None:
|
|
"""Read, ``change`` and rewrite an armed record under ``_record_mutex`` (an arm's new owner
|
|
stake is never overwritten by a stale copy). Never raises; no record, no write."""
|
|
path = _owed_path()
|
|
if _read_record(path) is None:
|
|
return
|
|
try:
|
|
with _record_mutex(path):
|
|
record = _read_record(path)
|
|
if record is None:
|
|
return
|
|
change(record)
|
|
_write_record(path, record)
|
|
except Exception as exc: # health: allow BLE001 -- never raises: a lost stamp costs a repeat restart, never the debt
|
|
logger.debug("Could not %s host update-restart obligation: %s", what, exc)
|
|
|
|
|
|
def host_restart_already_completed(sha: Optional[str]) -> bool:
|
|
"""True when THIS host obligation was already restarted onto ``sha``.
|
|
|
|
The guard that makes the catch-up restart idempotent per host: a second profile running
|
|
``hermes update`` must attach to the first restart's outcome, never kill the shared
|
|
multiplexer again.
|
|
"""
|
|
record = read_host_obligation()
|
|
if record is None or not sha:
|
|
return False
|
|
restarted = record.get("restarted")
|
|
return isinstance(restarted, dict) and str(restarted.get("sha") or "") == sha
|
|
|
|
|
|
def collapse_units_to_host_processes(
|
|
units: Iterable[str], main_pid: Callable[[str], int]
|
|
) -> tuple[list[str], dict[str, str]]:
|
|
"""Split enumerated units into ``(restart, {legacy_unit: covering_unit})``.
|
|
|
|
Units resolving to the same live ``MainPID`` are ONE host process; restarting each of them
|
|
restarts that process N times, which on a multiplexed host is an N-fold outage triggered by
|
|
leftover per-profile units. A unit with no readable main PID (inactive, unprivileged scope)
|
|
keeps its own restart: identity that cannot be proved is never collapsed away.
|
|
"""
|
|
restart: list[str] = []
|
|
covered: dict[str, str] = {}
|
|
owner_by_pid: dict[int, str] = {}
|
|
for unit in units:
|
|
try:
|
|
pid = int(main_pid(unit) or 0)
|
|
except Exception:
|
|
# Identity that cannot be proved keeps its own restart; a probe failure of any kind
|
|
# must never abort the whole pass.
|
|
pid = 0
|
|
if pid <= 0:
|
|
restart.append(unit)
|
|
continue
|
|
owner = owner_by_pid.get(pid)
|
|
if owner is None:
|
|
owner_by_pid[pid] = unit
|
|
restart.append(unit)
|
|
else:
|
|
covered[unit] = owner
|
|
return restart, covered
|