# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
177 lines
8.2 KiB
Python
177 lines
8.2 KiB
Python
"""External drain-control marker contract (dashboard → gateway).
|
|
|
|
No control channel exists into a running gateway, so begin/cancel-drain writes
|
|
(or removes) ``{HERMES_HOME}/.drain_request.json`` and a gateway watcher reacts;
|
|
an ACTIVE marker means ``gateway_state -> "draining"``. Two lenient staleness
|
|
signals (either suffices): epoch mismatch (HERMES_HOME is a durable volume on
|
|
Hermes Cloud, so a marker survives the restart a drain-gated action ends in and
|
|
would park the fresh gateway in ``draining`` forever) and expiry (same-epoch
|
|
orphan past :data:`DRAIN_REQUEST_MAX_AGE_SECONDS`; re-writing refreshes it).
|
|
Reading never raises: a malformed file reads as ``{}`` — still drain-active
|
|
(fail-safe toward quiescing). Staleness rejects only on a *definite* verdict.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import functools
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from gateway.memory_status import _parse_iso
|
|
from hermes_constants import get_hermes_home
|
|
from utils import atomic_json_write
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_DRAIN_REQUEST_FILENAME = ".drain_request.json"
|
|
# Drain-gated lifecycle actions complete in minutes; an hour bounds the wedge a leaked
|
|
# marker can cause. Long drains refresh the marker instead of raising this.
|
|
# Max-age fallback for a same-epoch orphaned marker (#85433). Long-running drains refresh the marker via
|
|
# write_drain_request() (idempotent re-write bumps ``requested_at``) rather than raising this bound.
|
|
DRAIN_REQUEST_MAX_AGE_SECONDS = 3600.0
|
|
# Dedup for the expired-marker warning (the watcher re-reads every second); keyed by
|
|
# ``requested_at`` so a keep-alive re-write that later expires logs again.
|
|
_expiry_logged_for: Optional[str] = None
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def current_instantiation_epoch() -> str:
|
|
"""Identity of THIS container / VM instantiation ("<boot_id>:<pid1_start>").
|
|
|
|
Stable for the life of PID 1 (a gateway-only respawn keeps honouring an
|
|
in-flight drain) but changes when the machine is recreated: boot_id on a VM
|
|
reboot, PID 1's start time on ``docker restart``. ``""`` when neither is
|
|
readable (non-Linux, no ``/proc``) disables the epoch check — never fail-closed.
|
|
"""
|
|
boot_id = pid1_start = ""
|
|
with contextlib.suppress(OSError):
|
|
boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="utf-8-sig").strip()
|
|
with contextlib.suppress(OSError, IndexError):
|
|
# "<pid> (<comm>) <state> ...": comm may contain spaces/parens, so split on the
|
|
# LAST ')'. starttime is field 22 (1-indexed) = tail index 19.
|
|
pid1_start = Path("/proc/1/stat").read_text(encoding="utf-8-sig").rsplit(")", 1)[1].split()[19]
|
|
return f"{boot_id}:{pid1_start}" if (boot_id or pid1_start) else ""
|
|
|
|
|
|
def drain_request_path(home: Optional[Path] = None) -> Path:
|
|
"""Absolute path to the drain-request marker, respecting HERMES_HOME."""
|
|
return Path(home if home is not None else get_hermes_home()) / _DRAIN_REQUEST_FILENAME
|
|
|
|
|
|
def write_drain_request(
|
|
*, principal: str = "drain-control", suppress_notification: bool = False, home: Optional[Path] = None
|
|
) -> dict[str, Any]:
|
|
"""Write the begin-drain marker atomically; returns the payload.
|
|
|
|
Re-writing refreshes ``requested_at`` (keep-alive past the max-age).
|
|
``suppress_notification`` skips ONLY the home-channel "gateway shutting down"
|
|
broadcast (the per-session interrupt ping is never suppressed); which drains
|
|
are quiet is the caller's policy. Stamped with the instantiation epoch so a
|
|
copy surviving a machine restart on the durable volume reads as stale.
|
|
"""
|
|
payload = {
|
|
"action": "drain", "requested_at": datetime.now(timezone.utc).isoformat(), "principal": principal,
|
|
"epoch": current_instantiation_epoch(), "suppress_notification": bool(suppress_notification),
|
|
}
|
|
atomic_json_write(drain_request_path(home), payload)
|
|
return payload
|
|
|
|
|
|
def clear_drain_request(*, home: Optional[Path] = None) -> bool:
|
|
"""Remove the drain marker (cancel-drain, idempotent). Returns True if one existed."""
|
|
path = drain_request_path(home)
|
|
try:
|
|
path.unlink()
|
|
return True
|
|
except OSError as e:
|
|
if not isinstance(e, FileNotFoundError):
|
|
_log.warning("drain-control: failed to remove %s: %s", path, e)
|
|
return False
|
|
|
|
|
|
def _marker_is_expired(body: dict[str, Any]) -> bool:
|
|
"""True iff ``requested_at`` parses AND is older than the max-age.
|
|
|
|
Missing/unparseable and future-dated (clock skew) timestamps are honoured.
|
|
Logged once per marker, not per poll — the operator's breadcrumb for a leak.
|
|
|
|
See #85433.
|
|
"""
|
|
global _expiry_logged_for
|
|
raw = body.get("requested_at")
|
|
requested_at = _parse_iso(raw)
|
|
if requested_at is None:
|
|
return False
|
|
age = (datetime.now(timezone.utc) - requested_at).total_seconds()
|
|
if age <= DRAIN_REQUEST_MAX_AGE_SECONDS:
|
|
return False
|
|
if _expiry_logged_for != raw:
|
|
_expiry_logged_for = raw
|
|
_log.warning(
|
|
"drain-control: ignoring expired drain marker (requested_at=%s, age=%.0fs > max %.0fs, principal=%s) "
|
|
"— the drain that wrote it was never cancelled; treating as stale so the gateway keeps accepting turns.",
|
|
raw, age, DRAIN_REQUEST_MAX_AGE_SECONDS, body.get("principal"),
|
|
)
|
|
return True
|
|
|
|
|
|
def _active_drain_body(home: Optional[Path]) -> Optional[dict[str, Any]]:
|
|
"""Marker body if present AND not stale (definite epoch mismatch or expired), else None."""
|
|
body = read_drain_request(home=home)
|
|
if body is None:
|
|
return None
|
|
current, marker_epoch = current_instantiation_epoch(), body.get("epoch")
|
|
if (current and marker_epoch and marker_epoch != current) or _marker_is_expired(body):
|
|
return None
|
|
return body
|
|
|
|
|
|
def drain_requested(*, home: Optional[Path] = None) -> bool:
|
|
"""True iff an active (present, same-epoch, unexpired) begin-drain marker exists.
|
|
|
|
A marker whose ``epoch`` does not match the current instantiation epoch is treated as absent: it
|
|
survived a container/VM restart (HERMES_HOME is a durable Fly volume on Hermes Cloud) and the lifecycle
|
|
action that triggered the drain has already completed — honouring it would wedge the freshly-restarted
|
|
gateway in ``draining`` (NS-570). A marker whose ``requested_at`` is older than
|
|
:data:`DRAIN_REQUEST_MAX_AGE_SECONDS` is likewise treated as absent: it is a same-epoch orphan whose
|
|
drain-gated action completed without a restart and was never cancelled (#85433). Both staleness checks
|
|
are lenient (see :func:`_marker_epoch_is_stale` / :func:`_marker_is_expired`): a legacy/corrupt marker
|
|
with no epoch and no timestamp, or an environment without ``/proc``, still reads as drain-active.
|
|
"""
|
|
return _active_drain_body(home) is not None
|
|
|
|
|
|
def drain_notification_suppressed(*, home: Optional[Path] = None) -> bool:
|
|
"""True iff an ACTIVE marker asks to suppress the shutdown broadcast.
|
|
|
|
Same activeness rule as :func:`drain_requested`, so an orphan can never silence
|
|
a fresh gateway; a marker without the field reads False (fail toward louder).
|
|
|
|
"Active" means exactly what :func:`drain_requested` means — a marker present AND stamped with the
|
|
current instantiation epoch AND not past its max-age. A stale (other-epoch) marker that survived a
|
|
machine restart on the durable HERMES_HOME volume, or an expired same-epoch orphan (#85433), is ignored
|
|
here just as it is for drain state (NS-570): we must never let an orphaned marker's flag silence a
|
|
*fresh* gateway's legitimate shutdown broadcast.
|
|
"""
|
|
body = _active_drain_body(home)
|
|
return bool(body and body.get("suppress_notification"))
|
|
|
|
|
|
def read_drain_request(*, home: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
|
"""Return the marker payload, ``{}`` if present but unparseable, ``None`` if absent. Never raises."""
|
|
path = drain_request_path(home)
|
|
try:
|
|
raw = path.read_text(encoding="utf-8-sig")
|
|
except OSError as e:
|
|
if not isinstance(e, FileNotFoundError):
|
|
_log.warning("drain-control: failed to read %s: %s", path, e)
|
|
return None
|
|
try:
|
|
data = json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|