Files
hermes-agent/hermes_cli/update_cmd_drain_report.py
Teknium 9248d847d0 fix(gateway): lifecycle verbs never act on another home's unit and refuse while sessions are live
A probe run with HOME=<scratch> resolved the bare `hermes-gateway` name (its scratch .hermes looked
native to _profile_suffix, which compared against the PROCESS home) while the user unit dir follows the
ACCOUNT home, so `hermes gateway stop` ran `systemctl --user stop hermes-gateway` on the account's real
gateway and interrupted its live sessions and background delegations.

Foreign-unit half (layered on the salvaged #141908 guard, one guard for both scopes):
- naming root: _native_service_homes / service_suffix compare against account_default_hermes_home()
  (pwd account home), the same root #141908 used for the sudo remap; a scratch HOME no longer names
  the bare unit.
- gateway_service_owner.foreign_systemd_unit_home / refuse_foreign_systemd_unit (moved from the
  facade, unchanged logic) cover user AND system units before any home sync; launchd verbs get the
  same refusal (refuse_foreign_launchd_plist).
- `stop --all` / `restart --all` kill only gateways whose live home is this install's
  (partition_gateway_pids_by_scope, the `hermes update` fleet rule).

Live-sessions half:
- `gateway stop` / `restart` refuse while the gateway runs turns, cron jobs or background
  delegations, naming each (describe_active_work_unit), unless `--interrupt` is passed.
- New `active-work` control verb answers live, including async delegations (absent from
  active_agents); a fresh gateway_state.json active_agents covers older gateways.
- Dashboard: POST /api/gateway/stop answers 409 naming the work (?interrupt=true overrides);
  config-save auto-restarts report the refusal. POST /api/gateway/restart stays the user-commanded
  force path (D6, 19b2624404) and the update recovery restart stays drain-first (3163731289):
  both pass --interrupt.
2026-10-20 05:30:29 -07:00

130 lines
6.0 KiB
Python

"""Name what a draining gateway is waiting on while ``hermes update`` blocks on it.
The gateway's in-band restart (SIGUSR1 → ``request_restart``) defers ``stop()`` until in-flight
work finishes, capped by ``agent.restart_after_turn_timeout`` (30 min by default). From the
updater's side that was a bare "draining (up to 1875s)..." followed by silence, which reads as a
hung update. The gateway publishes each unit it is holding for in ``gateway_state.json``
(``active_work``, written by ``GatewayShutdownMixin._describe_active_work``); this module turns
that into progress lines.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Callable, Optional
# Progress cadence: the gateway refreshes ``active_work`` every 30s; printing faster only repeats it.
DRAIN_REPORT_INTERVAL_S = 30.0
def _fmt_elapsed(seconds: object) -> str:
try:
total = int(float(seconds)) # type: ignore[arg-type]
except (TypeError, ValueError):
return "?"
return f"{total // 60}m{total % 60:02d}s" if total >= 60 else f"{total}s"
def _cron_job_name(job_id: str, home: Optional[Path]) -> Optional[str]:
"""``name`` from the profile's ``jobs.json`` (None when unreadable — the id alone still identifies it)."""
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from cron.jobs import load_jobs
token = set_hermes_home_override(home) if home else None
try:
for job in load_jobs():
if str(job.get("id")) == job_id:
return str(job.get("name") or "") or None
finally:
if token is not None:
reset_hermes_home_override(token)
except Exception:
return None
return None
def describe_active_work_unit(unit: dict, home: Optional[Path] = None) -> str:
"""One human line for one ``active_work`` entry; unknown shapes degrade to their ``kind``."""
kind = str(unit.get("kind") or "work")
pid = unit.get("pid")
pid_part = f" pid {pid}" if pid else ""
elapsed = unit.get("elapsed_s")
elapsed_part = f", running {_fmt_elapsed(elapsed)}" if elapsed is not None else ""
if kind == "cron":
job_id = str(unit.get("job_id") or "?")
name = _cron_job_name(job_id, home)
label = f"cron job {job_id}" + (f" ({name})" if name else "")
where = f" in external worker{pid_part}" if unit.get("external") else f" in-process{pid_part}"
return f"{label}{where}{elapsed_part}"
if kind == "chat":
session = str(unit.get("session") or "?")
model = unit.get("model")
tool = unit.get("current_tool")
detail = ", ".join(p for p in (f"model {model}" if model else "", f"tool {tool}" if tool else "") if p)
return f"chat turn {session}{pid_part}{elapsed_part}" + (f" [{detail}]" if detail else "")
if kind == "delegation":
goal = str(unit.get("goal") or "").strip()
return (f"background delegation {unit.get('delegation_id') or '?'}{pid_part}{elapsed_part}"
+ (f" [{goal}]" if goal else ""))
return f"{kind} run{pid_part}{elapsed_part}"
def read_active_work(home: Optional[Path] = None) -> Optional[list]:
"""``active_work`` as the gateway last published it, or None (old gateway / not draining / unreadable)."""
try:
from gateway.status import read_runtime_status
record = read_runtime_status(home / "gateway_state.json" if home else None) or {}
work = record.get("active_work")
return list(work) if isinstance(work, list) else None
except Exception:
return None
def _tui_command(home: Optional[Path]) -> str:
"""The TUI command that opens *home*'s profile: a bare ``hermes`` opens the default one."""
from hermes_constants import profile_name_for_home
name = profile_name_for_home(home)
return f"hermes -p {name} --tui" if name and name != "default" else "hermes --tui"
def format_drain_report(work: Optional[list], *, remaining_s: float, home: Optional[Path] = None) -> str:
"""Multi-line progress block: what the gateway is waiting on plus how to stop waiting."""
lines = [f" ⏳ still draining — {max(remaining_s, 0):.0f}s left before the forced restart"]
if work is None:
lines.append(" (gateway did not report what it is waiting on — pre-update gateway or unreadable state file)")
elif not work:
lines.append(" (no active work reported; the gateway should exit momentarily)")
else:
lines.append(f" waiting on {len(work)} active work unit(s):")
lines.extend(f" • {describe_active_work_unit(u, home)}" for u in work)
if any(isinstance(u, dict) and u.get("kind") == "chat" for u in work):
# The draining gateway stays reachable for this (#139041): a chat turn parked on an
# approval/clarify is answered from a client, not waited out.
lines.append(" a chat turn waiting on an approval or clarify question can be answered now: "
f"open it with `{_tui_command(home)}` (resume the session)")
lines.append(" finish or kill the work above to release the drain now; "
"agent.restart_after_turn_timeout in config.yaml caps this wait")
return "\n".join(lines)
def drain_progress_reporter(home: Optional[Path] = None, *, budget_s: float,
interval_s: float = DRAIN_REPORT_INTERVAL_S,
emit: Callable[[str], None] = print) -> Callable[[], None]:
"""Return a zero-arg callback for ``_wait_for_pid_exit(on_progress=...)`` that prints the drain
report every ``interval_s`` while the wait is in progress."""
started = time.monotonic()
state = {"last": started}
def _tick() -> None:
now = time.monotonic()
if now - state["last"] < interval_s:
return
state["last"] = now
emit(format_drain_report(read_active_work(home), remaining_s=budget_s - (now - started), home=home))
return _tick