Files
2026-07-13 13:12:00 +08:00

1996 lines
87 KiB
Python

"""Host process main loop for ``omnigent host``.
Connects to the server via WebSocket, registers as a host, and
listens for ``host.launch_runner`` / ``host.stop_runner`` frames.
Spawns runner subprocesses on demand and reports results back to
the server.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import subprocess
import sys
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
import websockets.asyncio.client
from websockets.exceptions import InvalidStatus, InvalidURI
from omnigent._platform import WINDOWS_ENV_PASSTHROUGH
from omnigent.env_credentials import env_names_with_omnigent_prefix
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostCreateDirResultFrame,
HostCreateWorktreeFrame,
HostCreateWorktreeResultFrame,
HostHelloFrame,
HostLaunchRunnerFrame,
HostLaunchRunnerResultFrame,
HostListDirEntry,
HostListDirFrame,
HostListDirResultFrame,
HostListWorktreesFrame,
HostListWorktreesResultFrame,
HostRemoveWorktreeFrame,
HostRemoveWorktreeResultFrame,
HostRunnerExitedFrame,
HostStatFrame,
HostStatResultFrame,
HostStopRunnerFrame,
HostStopRunnerResultFrame,
decode_host_frame,
encode_host_frame,
)
from omnigent.host.git_worktree import (
WorktreeError,
create_worktree,
list_worktrees,
remove_worktree,
)
from omnigent.host.identity import HostIdentity, load_or_create_host_identity
from omnigent.onboarding.harness_install import harness_setup_hint
from omnigent.onboarding.harness_readiness import (
configured_harness_map,
harness_is_configured,
)
from omnigent.runner.identity import (
RUNNER_ID_ENV_VAR,
RUNNER_PARENT_PID_ENV_VAR,
RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR,
RUNNER_WORKSPACE_ENV_VAR,
token_bound_runner_id,
)
from omnigent.runner.transports.ws_tunnel.frames import (
PingFrame,
PongFrame,
decode_frame,
encode_frame,
)
from omnigent.runner.transports.ws_tunnel.limits import (
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
)
from omnigent.version import VERSION
_logger = logging.getLogger(__name__)
def _runner_log_dir() -> Path:
"""Return the directory holding per-session runner logs for this host.
Each ``host.launch_runner`` writes its runner subprocess's captured
stdout/stderr to a ``runner-*.log`` file here. Computed at call time
(not a module constant) so tests that repoint ``Path.home`` see the
override.
:returns: The host-runner log directory, e.g.
``Path.home() / ".omnigent" / "logs" / "host-runner"``.
"""
return Path.home() / ".omnigent" / "logs" / "host-runner"
def _display_log_path(path: Path) -> str:
"""Format a log path for display, collapsing the home prefix to ``~``.
:param path: Absolute path, typically under the user's state dir, e.g.
``Path("/Users/alice/.omnigent/logs/host-runner/runner-ab12.log")``.
:returns: ``"~/.omnigent/..."`` when *path* is under ``$HOME``,
otherwise ``str(path)``.
"""
try:
return f"~/{path.relative_to(Path.home())}"
except ValueError:
# Not under $HOME (e.g. an OMNIGENT_DATA_DIR outside home).
return str(path)
# Max bytes read from the end of a dead runner's log when composing an
# exit report. 4 KiB is roughly the last 40-60 lines — enough to carry
# a Python traceback or the tunnel rejection message.
_LOG_TAIL_MAX_BYTES = 4096
# Max log-tail lines included in a runner exit report. The report ends
# up verbatim in a CLI error message, so it must stay short enough that
# the error summary above it remains visible.
_LOG_TAIL_MAX_LINES = 15
# Poll cadence for the per-runner exit watcher. 0.5s matches the
# client's online-poll cadence (daemon_launch.DAEMON_POLL_INTERVAL_S),
# so a crashed runner is reported within about one client poll.
_RUNNER_WATCH_INTERVAL_S = 0.5
# Cadence of the orphan-reaper sweep. The host installs itself as a child
# subreaper (Linux — see :func:`_install_child_subreaper`), so a harness's
# detached tool subprocess (node/npm/chromium/tmux/python) whose runner
# parent died reparents to the host. With no reaper such an orphan lingers
# as a ``<defunct>`` zombie; over an overnight blocked run they reached
# ~900 zombies and OOM'd the box (#1782). A ``WNOHANG`` sweep is a cheap
# syscall, so 2s keeps zombie lifetime short at negligible cost.
_ORPHAN_REAP_INTERVAL_S = 2.0
def _install_child_subreaper() -> bool:
"""Make this process reap orphaned descendants (Linux only).
``prctl(PR_SET_CHILD_SUBREAPER, 1)`` asks the kernel to reparent any
orphaned descendant — e.g. a harness's detached tool subprocess whose
runner parent exited — to THIS process instead of PID 1, so the host's
orphan reaper can ``wait()`` on it even when the host is not itself
PID 1 (e.g. ``omni host --server`` launched under a shell). When the
host already IS PID 1 (container entrypoint) orphans reparent here
regardless and this call is a harmless no-op.
Complements — does not replace — the per-runner ``_watch_runner``
reaping of the host's own direct children.
:returns: ``True`` if the subreaper bit was set; ``False`` on non-Linux
or if ``prctl`` is unavailable. Both are non-fatal: direct-child
reaping and the PID-1 case still work; only the non-PID-1 orphan
case degrades.
"""
if sys.platform != "linux":
return False
try:
import ctypes
libc = ctypes.CDLL("libc.so.6", use_errno=True)
_PR_SET_CHILD_SUBREAPER = 36
return libc.prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0
except (OSError, AttributeError):
return False
def _read_log_tail(path: Path, max_bytes: int = _LOG_TAIL_MAX_BYTES) -> str:
"""Read the last portion of a runner log file for diagnostics.
:param path: The runner's captured stdout/stderr log file, e.g.
``Path("~/.omnigent/logs/host-runner/runner-ab12.log")``.
:param max_bytes: Max bytes to read from the end of the file,
e.g. ``4096``.
:returns: The decoded tail (lossy UTF-8 — runner output may
contain arbitrary bytes), or ``""`` when the file is empty,
missing, or unreadable. Diagnostics are best-effort: an
unreadable log must not turn a useful "runner died with code
1" answer into a failure.
"""
try:
with path.open("rb") as fh:
fh.seek(0, os.SEEK_END)
size = fh.tell()
fh.seek(max(0, size - max_bytes))
return fh.read().decode("utf-8", errors="replace")
except OSError:
return ""
def _runner_exit_error(exit_code: int | None, log_path: Path) -> str:
"""Compose the human-readable error for a runner that died.
Carries the actual cause to the user: exit code, the host-side log
path (for the full log), and the trailing log lines — the part that
usually holds the traceback or tunnel-rejection message. Without
this, the cause stays in a file on the host and every consumer just
sees a connect timeout.
:param exit_code: The runner process's exit code, e.g. ``1``.
``None`` when unknown.
:param log_path: The runner's captured stdout/stderr log file.
:returns: A multi-line error message ready to surface verbatim in
a CLI error or API ``error`` field.
"""
message = "runner process exited"
if exit_code is not None:
message += f" with code {exit_code}"
message += f" (log on host: {_display_log_path(log_path)})"
tail = _read_log_tail(log_path)
if tail.strip():
lines = tail.strip().splitlines()[-_LOG_TAIL_MAX_LINES:]
message += "\n--- runner log tail ---\n" + "\n".join(lines)
return message
def _url_is_loopback(url: str) -> bool:
"""Whether ``url``'s host is loopback (``127.0.0.1`` / ``localhost`` / ``::1``).
Used to distinguish a daemon-spawned local server (no proxy in
front) from a remote deploy behind the Databricks Apps ingress, so
the reconnect heuristic only treats an abrupt ``no close frame`` as
a benign ingress recycle when there actually IS an ingress.
:param url: A server or ws:// URL, e.g. ``"ws://127.0.0.1:49175"``.
:returns: ``True`` for a loopback host, ``False`` otherwise (incl.
unparseable URLs — fail toward "remote", the safer default for
the recycle heuristic).
"""
from urllib.parse import urlparse
try:
return urlparse(url).hostname in ("127.0.0.1", "localhost", "::1")
except ValueError:
return False
_RECONNECT_BASE_S = 0.5
_RECONNECT_CAP_S = 10.0
_RECONNECT_JITTER = 0.5
# Host-environment variables a spawned runner is allowed to inherit.
# Deliberately an allowlist (not ``{**os.environ}``): the host runs as the
# user, so its environment holds the user's personal secrets (API keys,
# tokens). A runner has no need for those — agent credentials and config
# come from the agent spec, not the host owner's shell (spec
# self-containment). Anything an agent
# legitimately needs must flow through its spec's env config. Limited to
# process essentials (PATH/HOME/shell/locale/temp) and TLS trust stores so
# the runner's outbound HTTPS still works.
_RUNNER_ENV_ALLOWLIST: frozenset[str] = frozenset(
{
"PATH",
"PYTHONPATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"TZ",
"TERM",
"TERMINFO",
"TERMINFO_DIRS",
"LANG",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
# Environment descriptor baked into the sandbox host image
# (deploy/docker/Dockerfile `host` target), never set on
# laptops. Claude Code refuses --dangerously-skip-permissions
# under root unless this devcontainer-convention flag is set,
# and sandbox containers run as root — without it the
# claude-sdk harness cannot start inside managed sandboxes.
"IS_SANDBOX",
# Databricks config selectors are not bearer secrets. They must
# reach host-spawned runners so native harnesses resolve the same
# profile/config file the host resolved (e.g. a spec-declared
# executor.profile propagated into the daemon's env).
"DATABRICKS_CONFIG_PROFILE",
"DATABRICKS_CONFIG_FILE",
# DATABRICKS_AUTH_STORAGE selects the token-storage backend ("secure"
# OS keychain vs "plaintext" JSON cache) — also a non-secret selector.
# Without it a runner falls back to the ~/.databrickscfg [__settings__]
# auth_storage default and can resolve a DIFFERENT token store than the
# host/daemon (which inherits it via the daemon env's DATABRICKS_ prefix
# in cli.py). That mismatch makes the runner read an empty/stale store
# and fail to mint a token — the runner tunnel is rejected with HTTP 401
# even though the host authenticated fine.
"DATABRICKS_AUTH_STORAGE",
# Runtime config/data-dir selection. These are filesystem PATHS, not
# secrets, so they're safe to propagate to the host owner's own
# daemon/runner subprocesses. They MUST propagate so the whole local
# chain (CLI → daemon → local server → runner) agrees:
# - OMNIGENT_CONFIG_HOME: where config.yaml / provider config live,
# so the runner resolves the same providers the CLI configured.
# - OMNIGENT_DATA_DIR: where the sqlite db + pidfile live, so the
# CLI doesn't read the local-server pidfile from one dir while the
# daemon writes it to another (that mismatch timed out discovery).
# OMNIGENT_DATABASE_URI is intentionally NOT here — it may embed a
# DB password, so it's propagated to the local daemon only (see
# cli._ensure_host_daemon), never to a (possibly hosted) runner.
"OMNIGENT_CONFIG_HOME",
"OMNIGENT_DATA_DIR",
# Auth provider selection. The env-unset default was flipped
# to "accounts", so the whole CLI → daemon → local-server chain has
# to agree on the mode. Without this, the daemon strips
# OMNIGENT_AUTH_PROVIDER and the daemon-spawned local server
# silently boots in accounts mode while the CLI thinks it's talking
# to a header-mode server — every CLI request 401s (e.g. the
# test_run_omnigent_resumption suite). Not a secret; safe to propagate to
# any subprocess.
"OMNIGENT_AUTH_PROVIDER",
# Multi-user opt-in switch (create_auth_provider): OMNIGENT_AUTH_ENABLED
# turns the env-unset header/local default into accounts (or oidc, when
# OMNIGENT_OIDC_* is set); =0 opts back out. Must propagate down the
# CLI → daemon → local-server chain or `omnigent run`/`connect` would
# spawn the wrong auth mode while the operator set the switch on the CLI.
# Not a secret. OMNIGENT_ACCOUNTS_ENABLED is the deprecated pre-rename
# alias, still propagated so existing setups keep working.
"OMNIGENT_AUTH_ENABLED",
"OMNIGENT_ACCOUNTS_ENABLED",
# Secret-store backend selector. The CLI's `configure harnesses` stores
# pasted API keys via the file backend when this is set (headless /
# locked-keyring hosts), writing `keychain:<name>` refs. The runner
# RESOLVES those refs, so it must pick the SAME backend — otherwise it
# falls back to the OS keyring and fails with "no stored secret named
# …" for a key the CLI just saved to the file. Not a secret (a boolean
# flag); safe to propagate.
"OMNIGENT_DISABLE_KEYRING",
# claude-sdk sandbox bypass flag. A diagnostic knob (not a
# secret — a plain boolean) read inside the harness to decide
# whether to wrap the brain CLI in sandbox-exec. Without it in
# the allowlist the daemon→runner env strip drops it, so a bare
# ``OMNIGENT_CLAUDE_SDK_NO_SANDBOX=1 omnigent run …`` had no
# effect (the operator also had to set
# ``OMNIGENT_RUNNER_ENV_PASSTHROUGH=OMNIGENT_CLAUDE_SDK_NO_SANDBOX``).
# Safe to propagate: not a secret.
"OMNIGENT_CLAUDE_SDK_NO_SANDBOX",
# Native-Claude launcher plugin selector: the entry-point NAME of a
# launcher registered in the ``omnigent.claude_launcher`` group (e.g.
# ``isaac``). Read by omnigent.claude_launcher.resolve_claude_launch in
# the managed-host runner (``_auto_create_claude_terminal``) to wrap the
# Claude launch through a downstream binary (e.g. Databricks' isaac).
# The daemon→runner env strip would otherwise drop it, leaving the
# runner on the default launch. Safe to propagate: not a secret, just a
# plugin name.
"OMNIGENT_CLAUDE_LAUNCHER",
# Testing knob: override the context window size for compaction
# trigger threshold. Not a secret — a plain integer.
"AP_CONTEXT_WINDOW_OVERRIDE",
# Claude Code's Bedrock-mode switch: a non-secret boolean flag that
# turns on AWS Bedrock / Bedrock-compatible gateway mode. The matching
# credential (AWS_BEARER_TOKEN_BEDROCK) and endpoint
# (ANTHROPIC_BEDROCK_BASE_URL) are NOT here: they are credentials and
# live in HARNESS_CREDENTIAL_ENV_VARS, mirroring ANTHROPIC_API_KEY /
# ANTHROPIC_BASE_URL. Safe to propagate: not a secret.
"CLAUDE_CODE_USE_BEDROCK",
# Claude Code's Bedrock-auth-skip switch: a non-secret boolean flag
# that disables AWS SigV4 auth so Claude Code can talk to a LiteLLM
# proxy fronting Bedrock. Without it the runner attempts native AWS
# auth, which fails for non-AWS proxies. Same rationale as
# CLAUDE_CODE_USE_BEDROCK above. Safe to propagate: not a secret.
"CLAUDE_CODE_SKIP_BEDROCK_AUTH",
# Kubernetes config path. A filesystem path (typically
# ``~/.kube/config``), not a bearer secret — the file *contains*
# cluster certs/tokens but the env var is just a path string,
# analogous to ``HOME``. Without it, ``kubectl`` / helm / k9s
# inside the agent's shell fall back to the default path which may
# not match what the host owner configured (e.g. a non-standard
# kubeconfig location or a colon-separated multi-file list).
"KUBECONFIG",
# Telemetry master opt-in. MUST propagate, or the daemon-spawned runner
# (and the harness it spawns) never see OMNIGENT_TELEMETRY_ENABLED, so
# telemetry.init() no-ops there and omni-runner / omni-harness export
# nothing — inheriting OTEL_* alone is no longer enough now that
# telemetry is opt-in. Not a secret (a boolean). The OMNIGENT_OTEL_*
# knobs (capture-content, FastAPI toggle) ride the prefix allowlist below.
"OMNIGENT_TELEMETRY_ENABLED",
# Opaque request-routing headers (dev/test): a JSON header map folded by
# cli_auth.databricks_request_headers into every client→server connection
# so a request pins to a specific server instance/replica. Must reach the
# spawned runner so its tunnel + server callbacks route to the SAME
# instance the host registered on — otherwise the host lands on the
# selected instance while its runners fall back to the default one.
# Routing config, not a secret; unset in prod. Allowlisting it forwards it
# host→runner intrinsically, so the setter need not also list it in
# OMNIGENT_RUNNER_ENV_PASSTHROUGH.
"OMNIGENT_DATABRICKS_EXTRA_HEADERS",
}
# Windows system / profile constants (SYSTEMROOT is mandatory for Winsock,
# USERPROFILE for Path.home(), etc.); a no-op on POSIX. See _platform.
| set(WINDOWS_ENV_PASSTHROUGH)
)
# Allowed by prefix: locale family (``LC_*``), MLflow, and OpenTelemetry config —
# both the standard ``OTEL_*`` vars and Omnigent's ``OMNIGENT_OTEL_*`` knobs
# (capture-content, FastAPI toggle) so they reach the runner/harness too.
_RUNNER_ENV_ALLOWLIST_PREFIXES: tuple[str, ...] = ("LC_", "MLFLOW_", "OTEL_", "OMNIGENT_OTEL_")
# Harness credential / endpoint env vars forwarded host→runner when
# present. These are the names the harnesses themselves resolve —
# ANTHROPIC_* for claude-sdk / pi (claude-code also honors
# ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL for gateways, and
# ANTHROPIC_MODEL to pin a gateway-served model (must travel with the
# key/endpoint, else native Claude launches with a default the gateway
# rejects), AWS_BEARER_TOKEN_BEDROCK + ANTHROPIC_BEDROCK_BASE_URL for Bedrock mode,
# and CLAUDE_CODE_OAUTH_TOKEN for `claude setup-token` subscription auth),
# OPENAI_* for codex / openai-agents (CODEX_ACCESS_TOKEN is the codex
# CLI's headless ChatGPT-workspace credential, minted in the ChatGPT
# admin console — Business/Enterprise plans), GEMINI_API_KEY for the
# gemini family. GIT_TOKEN / GIT_USERNAME feed the sandbox host
# image's git credential helper (deploy/docker/Dockerfile `host`
# target) so the agent's own fetch/push against a private repository
# authenticates, not just the launch-time clone. Unlike the rest of
# the host's environment, these are credentials the host owner sets
# PRECISELY so their runners can use them (on a laptop: exported keys;
# on a server-managed sandbox: the deployment's injected provider
# secrets) — forwarding them is the intent, not a leak. Vars absent
# from the host env are simply not set.
_BASE_HARNESS_CREDENTIAL_ENV_VARS: frozenset[str] = frozenset(
{
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL",
"ANTHROPIC_BEDROCK_BASE_URL",
"AWS_BEARER_TOKEN_BEDROCK",
"CLAUDE_CODE_OAUTH_TOKEN",
"CODEX_ACCESS_TOKEN",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"GEMINI_API_KEY",
"GIT_TOKEN",
"GIT_USERNAME",
}
)
HARNESS_CREDENTIAL_ENV_VARS: frozenset[str] = frozenset(
name
for canonical in _BASE_HARNESS_CREDENTIAL_ENV_VARS
for name in env_names_with_omnigent_prefix(canonical)
)
# Comma-separated EXTRA env var names to forward host→runner, beyond
# HARNESS_CREDENTIAL_ENV_VARS — for provider wiring the defaults don't
# cover (custom gateway vars, `providers:`-config `env:` refs, exotic
# SDK knobs). Operator-controlled: the host owner names exactly what
# their runners need; everything unnamed stays behind the allowlist.
RUNNER_ENV_PASSTHROUGH_ENV_VAR: str = "OMNIGENT_RUNNER_ENV_PASSTHROUGH"
# HTTP statuses on the WebSocket upgrade that are worth retrying. Everything
# else in the 4xx range is a permanent client error (auth, authorization,
# wrong/old server) where reconnecting can never succeed — those fail loud.
# 408 (Request Timeout) and 429 (Too Many Requests) are transient by HTTP
# semantics, so they stay in the reconnect path.
_RETRYABLE_UPGRADE_STATUSES: frozenset[int] = frozenset({408, 429})
# Consecutive login-page redirects tolerated on a host that has NEVER
# completed a WS upgrade in this process. A single redirect can be a server
# mid-restart (the Apps OAuth proxy answers before the app is ready),
# so a couple of retries rule out a blip; past that, a host with
# no prior successful upgrade is almost certainly unauthenticated and must
# fail loud instead of looping silently forever. A host that
# HAS connected keeps retrying indefinitely, so a deploy restart never
# kills a live host with running sessions.
_LOGIN_REDIRECT_FATAL_ATTEMPTS = 3
class HostConnectError(Exception):
"""A non-retryable failure while opening the host tunnel.
Raised when the WebSocket upgrade fails in a way that reconnecting
can never fix — the Databricks Apps proxy bounced the connection to
a login page (wrong/absent workspace credentials), or the server
returned a permanent ``4xx`` (unauthenticated, unauthorized, or a
build that predates the host API). The reconnect loop re-raises this
instead of backing off, so ``omnigent host`` exits with an
actionable message rather than looping silently forever.
The message is the full, user-facing explanation including the
suggested fix; it is printed verbatim by :func:`run_host_process`.
"""
def _build_runner_env(
base_env: Mapping[str, str],
*,
server_url: str,
runner_id: str,
binding_token: str,
workspace: str,
parent_pid: int,
) -> dict[str, str]:
"""
Build the environment for a spawned runner subprocess.
Inherits only the allowlisted subset of *base_env* (see
:data:`_RUNNER_ENV_ALLOWLIST`) so the host owner's secrets don't leak
into runners, then layers on the runner wiring vars.
Harness credentials are the deliberate exception to the allowlist:
the names in :data:`HARNESS_CREDENTIAL_ENV_VARS` (plus any extras
the host owner lists in :data:`RUNNER_ENV_PASSTHROUGH_ENV_VAR`)
forward when present, so runners can authenticate to LLM providers
with the credentials the host owner provisioned for them.
:param base_env: Host process environment to filter, e.g.
``os.environ``.
:param server_url: Omnigent server URL the runner connects back to, e.g.
``"https://example.databricks.com"``.
:param runner_id: Token-bound runner id, e.g. ``"runner_abc123"``.
:param binding_token: One-time tunnel binding token.
:param workspace: Absolute runner cwd on the host, e.g.
``"/Users/alice/proj"``.
:param parent_pid: Host process pid, for orphan detection.
:returns: The runner subprocess environment.
"""
extra_names = {
name.strip()
for name in base_env.get(RUNNER_ENV_PASSTHROUGH_ENV_VAR, "").split(",")
if name.strip()
}
forwarded = HARNESS_CREDENTIAL_ENV_VARS | extra_names
env = {
key: value
for key, value in base_env.items()
if key in _RUNNER_ENV_ALLOWLIST
or key.startswith(_RUNNER_ENV_ALLOWLIST_PREFIXES)
or key in forwarded
}
env["RUNNER_SERVER_URL"] = server_url
env[RUNNER_ID_ENV_VAR] = runner_id
env[RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR] = binding_token
env[RUNNER_WORKSPACE_ENV_VAR] = workspace
env[RUNNER_PARENT_PID_ENV_VAR] = str(parent_pid)
return env
def _paginate_list_dir(
*,
entries: list[HostListDirEntry],
request_id: str,
limit: int,
after: str | None,
before: str | None,
) -> HostListDirResultFrame:
"""
Slice a sorted directory listing into a page.
Cursors (``after`` / ``before``) reference an entry's ``path``.
Forward pagination (``after``) returns up to ``limit`` entries
strictly after the cursor; backward pagination (``before``)
returns up to ``limit`` entries strictly before. Empty cursors
return the first page. ``has_more`` is set when more entries
remain in the pagination direction: forward of the page for
``after``, before the page for ``before``.
:param entries: Full sorted list of directory entries.
:param request_id: Request id to echo back on the result frame.
:param limit: Max entries per page, e.g. ``20``. Capped at
1000 by the route layer.
:param after: Cursor for forward pagination. ``None`` → start
at the first entry.
:param before: Cursor for backward pagination. ``None`` → no
upper bound.
:returns: A list_dir result frame with the requested page.
"""
# Identify the cut points by entry path so cursors survive
# concurrent directory mutations between calls.
start = 0
end = len(entries)
if after is not None:
for idx, entry in enumerate(entries):
if entry.path == after:
start = idx + 1
break
if before is not None:
for idx, entry in enumerate(entries):
if entry.path == before:
end = idx
break
if before is not None:
page_start = max(start, end - limit)
page = entries[page_start:end]
has_more = page_start > start
else:
page = entries[start:end][:limit]
has_more = end - start > limit
return HostListDirResultFrame(
request_id=request_id,
status="ok",
entries=page,
has_more=has_more,
)
@dataclass
class _RunnerHandle:
"""A spawned runner subprocess and where its output lands.
:param proc: The runner subprocess handle.
:param log_path: File capturing the runner's stdout/stderr, e.g.
``Path("~/.omnigent/logs/host-runner/runner-ab12.log")``.
Read back for diagnostics when the runner dies before
connecting its tunnel.
"""
proc: subprocess.Popen[bytes]
log_path: Path
class HostProcess:
"""Manages the host daemon lifecycle.
Connects to the server, handles launch/stop commands, and
tracks spawned runner subprocesses.
:param identity: Host identity (id + name) from ``config.yaml``.
:param server_url: Omnigent server URL, e.g.
``"https://omnigent-app.databricksapps.com"``.
"""
def __init__(
self,
identity: HostIdentity,
server_url: str,
) -> None:
"""Initialize the host process.
:param identity: Host identity from ``config.yaml``.
:param server_url: Server URL to connect to.
"""
self._identity = identity
self._server_url = server_url.rstrip("/")
self._runners: dict[str, _RunnerHandle] = {}
# Set on the first accepted WS upgrade. Distinguishes a host that
# never authenticated (login redirects turn fatal after
# _LOGIN_REDIRECT_FATAL_ATTEMPTS) from a live host hit by a server
# restart (login redirects retry forever).
self._ever_connected = False
# Consecutive login-page redirects; reset by a successful upgrade.
self._login_redirect_streak = 0
# Live tunnel connection, set by _serve_frames for the watcher
# tasks (which outlive any single connection) to report on.
self._ws: websockets.asyncio.client.ClientConnection | None = None
# runner_id → composed error for exits that could not be sent
# (tunnel down at the time). Flushed after the next hello.
self._unreported_exits: dict[str, str] = {}
# Strong refs to per-runner watcher tasks; asyncio only keeps
# weak refs, so an unreferenced task can be GC'd mid-flight.
self._watcher_tasks: set[asyncio.Task[None]] = set()
# Strong ref to the orphan-reaper task (see :meth:`_orphan_reaper_loop`).
self._reaper_task: asyncio.Task[None] | None = None
# Number of host-owned ``subprocess`` operations (e.g. the git worktree
# commands in :mod:`omnigent.host.git_worktree`) currently in flight.
# The orphan reaper skips its sweep while this is >0 so it never
# ``wait()``s a child that ``subprocess.run`` is about to reap itself —
# stealing it would corrupt that command's returncode to 0 (#1782).
# Mutated only via :meth:`_host_subprocess_op`; safe as a plain int
# because both the mutation and the reaper run on the event loop.
self._owned_subprocess_ops = 0
def _tracked_runner_pids(self) -> set[int]:
"""PIDs of runners this host spawned and still tracks directly.
The orphan reaper must NOT ``wait()`` these: their exit status is
owned by their :class:`subprocess.Popen` (read via ``poll()`` /
``.returncode`` for the ``host.runner_exited`` report). Reaping one
out from under ``Popen`` makes ``poll()`` either spin forever
(``while poll() is None`` in ``_watch_runner``) or report a bogus
exit 0 for a crash — so the reaper skips these and lets
``_watch_runner`` / ``_handle_stop`` own them.
:returns: Set of live tracked runner OS pids.
"""
return {h.proc.pid for h in self._runners.values()}
async def _orphan_reaper_loop(self) -> None:
"""Reap orphaned descendant processes reparented to this host.
A harness spawns its tool subprocesses detached
(``start_new_session=True`` — ``omnigent.inner._proc.spawn_kwargs``),
so when the runner that owns them dies, those grandchildren
(``node`` / ``npm`` / ``chromium`` / ``tmux`` / ``python``) are
orphaned and reparented to this host (it is PID 1 in a container, or
a child subreaper otherwise — see :func:`_install_child_subreaper`).
Nothing ``wait()``s them, so each becomes a permanent ``<defunct>``
zombie; a blocked overnight run accumulated ~900 and OOM'd the box
(#1782).
This loop periodically reaps any ready-to-reap child that is NOT a
Popen-tracked runner (:meth:`_tracked_runner_pids`), draining zombies
without disturbing runner exit reporting. Non-Linux (no reparenting)
and the "no orphans yet" case both make this a cheap no-op sweep.
:returns: None. Runs until cancelled on shutdown.
"""
while True:
try:
await asyncio.sleep(_ORPHAN_REAP_INTERVAL_S)
self._reap_orphans_once()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — a reaper must never die on a stray error
_logger.debug("orphan reaper sweep failed", exc_info=True)
def _reap_orphans_once(self) -> int:
"""Reap ready orphaned children without corrupting runner exits.
The hazard: the host reads each runner's exit status through its
:class:`subprocess.Popen` (``poll()`` / ``.returncode``) for the
``host.runner_exited`` report. A blind ``waitpid(-1)`` reaper that
consumes a just-crashed tracked runner makes ``Popen.poll()`` report
a bogus exit 0 (verified) — the crash cause is lost. So the reaper
must drain orphans while leaving tracked runners' status intact.
Two implementations, same guarantee:
* **Linux/POSIX with** ``os.waitid`` — *peek* at the next reapable
child with ``WNOWAIT`` (does not consume). Reap it only if it is
not a tracked runner; if it is, stop the sweep and let the runner's
own Popen reaper (``_watch_runner``) consume it. Cleanest: a tracked
runner's status is never touched.
* **Platforms without** ``os.waitid`` **(e.g. macOS)** — ``waitpid``
has no peek, so reap with ``WNOHANG`` and, if the reaped pid is a
tracked runner, re-inject its exit status onto the ``Popen`` so
``_watch_runner`` still reports the true code. Safe because
``_reap_orphans_once`` runs to completion on the event loop without
awaiting, so it cannot interleave with ``_watch_runner`` /
``_handle_stop``.
This runs only when the host is PID 1 (container) or a child
subreaper (:func:`_install_child_subreaper`); otherwise no orphan
ever reparents here and every sweep is a no-op.
:returns: Count of orphan (non-runner) processes reaped this sweep.
"""
if self._owned_subprocess_ops > 0:
# A host-owned subprocess (e.g. a git worktree command) is running
# in a worker thread. Its child is a DIRECT child of this process
# but NOT a tracked runner, so it is indistinguishable from an
# orphan to the reaper — reaping it would steal it from
# ``subprocess.run``'s own ``wait()`` and corrupt that command's
# returncode to 0 (CPython swallows the ECHILD and reports 0).
# Skip this sweep; a later one drains any real orphans once the op
# finishes. A worktree op can hold this off for up to
# ``_GIT_TIMEOUT_S`` (120s) per git command, so real orphans can
# linger that long in the rare case a runner dies mid-worktree-op —
# acceptable, since the leak this guards against accrues over hours,
# not a two-minute worst case.
return 0
if hasattr(os, "waitid") and hasattr(os, "P_ALL"):
return self._reap_orphans_waitid()
return self._reap_orphans_waitpid()
@contextlib.contextmanager
def _host_subprocess_op(self) -> Iterator[None]:
"""Mark a host-owned ``subprocess`` operation as in flight.
Wrap any host-owned :mod:`subprocess` call (or the ``to_thread`` that
runs it) in this so the orphan reaper pauses and cannot ``wait()`` the
child out from under ``subprocess``'s own reaping — see
:meth:`_reap_orphans_once` for why that would corrupt the command's
exit code (#1782).
Increment/decrement run on the event loop (the reaper does too), so a
plain counter needs no lock. Re-entrant and exception-safe: the
decrement is in a ``finally``.
:returns: A context manager; the body runs with the reaper paused.
"""
self._owned_subprocess_ops += 1
try:
yield
finally:
self._owned_subprocess_ops -= 1
def _reap_orphans_waitid(self) -> int:
"""Peek-and-reap using ``os.waitid(WNOWAIT)`` (Linux/POSIX).
:returns: Count of orphan processes reaped.
"""
reaped = 0
tracked = self._tracked_runner_pids()
while True:
try:
info = os.waitid(os.P_ALL, 0, os.WEXITED | os.WNOHANG | os.WNOWAIT)
except (ChildProcessError, OSError):
break
if info is None:
break # children exist but none ready to reap
pid = info.si_pid
if pid in tracked:
# Leave it for _watch_runner's Popen to reap+report. Break, not
# continue: WNOWAIT keeps returning the same head pid, so
# continuing would spin. The runner is reaped within ~0.5s and
# the next sweep proceeds past it.
break
try:
os.waitpid(pid, 0) # consume the orphan
reaped += 1
except ChildProcessError:
break
if reaped:
_logger.debug("orphan reaper reaped %d process(es)", reaped)
return reaped
def _reap_orphans_waitpid(self) -> int:
"""Reap with ``waitpid(WNOHANG)``, re-injecting tracked-runner status.
Fallback for platforms without ``os.waitid`` (no peek). See
:meth:`_reap_orphans_once` for why re-injection is race-free.
:returns: Count of orphan (non-runner) processes reaped.
"""
reaped = 0
while True:
try:
pid, status = os.waitpid(-1, os.WNOHANG)
except (ChildProcessError, OSError):
break
if pid == 0:
break # children exist but none ready
handle = self._runner_handle_for_pid(pid)
if handle is not None:
# A tracked runner — do NOT count it as an orphan. Re-inject
# the status so its Popen (and thus _watch_runner) reports the
# true exit code instead of ECHILD → bogus 0.
if handle.proc.returncode is None:
handle.proc.returncode = os.waitstatus_to_exitcode(status)
continue
reaped += 1
if reaped:
_logger.debug("orphan reaper reaped %d process(es)", reaped)
return reaped
def _runner_handle_for_pid(self, pid: int) -> _RunnerHandle | None:
"""Return the tracked runner handle owning *pid*, or ``None``.
:param pid: An OS process id observed by the reaper.
:returns: The matching :class:`_RunnerHandle`, or ``None`` if *pid*
is not a tracked runner (i.e. an orphan to reap).
"""
for handle in self._runners.values():
if handle.proc.pid == pid:
return handle
return None
def _alive_runner_ids(self) -> list[str]:
"""Return IDs of runners that are still alive.
Cleans up dead entries as a side effect.
:returns: List of alive runner ID strings.
"""
dead = [rid for rid, handle in self._runners.items() if handle.proc.poll() is not None]
for rid in dead:
self._runners.pop(rid)
return list(self._runners.keys())
def _tunnel_url(self) -> str:
"""Build the WebSocket tunnel URL.
:returns: Full WS URL, e.g.
``"wss://server/v1/hosts/host_abc/tunnel"``.
"""
base = self._server_url
scheme = "wss" if base.startswith("https") else "ws"
host_part = base.split("://", 1)[1] if "://" in base else base
return f"{scheme}://{host_part}/v1/hosts/{self._identity.host_id}/tunnel"
def _credentials_fix_hint(self) -> str:
"""Build the remedy for a credential failure.
Shared by the login-redirect and HTTP 401 messages.
:returns: An actionable remedy sentence naming the exact
command, e.g. ``"Run `omnigent login <url>` ..."``.
"""
return (
f"Run `omnigent login {self._server_url}` to authenticate (it "
"detects Databricks-fronted servers and logs in to the right "
"workspace), or check your ambient Databricks credentials."
)
def _login_fix_hint(self) -> str:
"""Suggest ``omnigent login`` as a remedy for an auth rejection.
The host tunnel's bearer is resolved from a stored ``omnigent
login`` record first, then ambient Databricks credentials (see
:func:`omnigent.runner._entry._make_auth_token_factory`). When
the server runs Omnigent accounts or OIDC auth, a Databricks
workspace token can authenticate at the proxy yet still be rejected
by the server itself — so the actionable fix is to log in to the
server directly, which stores the session token the tunnel needs.
:returns: A one-sentence remedy naming the exact command, e.g.
``"If this server uses Omnigent accounts or OIDC login, run
`omnigent login http://localhost:6767` to authenticate."``.
"""
return (
"If this server uses Omnigent accounts or OIDC login, run "
f"`omnigent login {self._server_url}` to authenticate."
)
def _fatal_upgrade_error(self, exc: InvalidURI | InvalidStatus) -> HostConnectError | None:
"""Classify a WebSocket-upgrade failure as fatal, or return ``None``.
Distinguishes permanent failures (auth / authorization / wrong or
outdated server) from transient ones (server bounce, network blip)
so the reconnect loop only backs off on the latter.
Login-page redirects (``InvalidURI``) are ambiguous: they mean
missing/wrong credentials, but also occur transiently while the
server restarts behind the Apps OAuth proxy. They become fatal
only on a host that has never completed an upgrade in this
process, after :data:`_LOGIN_REDIRECT_FATAL_ATTEMPTS` consecutive
occurrences; an already-connected host retries them forever.
:param exc: The upgrade-time exception raised while opening the
tunnel — either an :class:`~websockets.exceptions.InvalidURI`
(redirect to a non-ws scheme) or an
:class:`~websockets.exceptions.InvalidStatus` carrying e.g. a
``403`` upgrade response.
:returns: A :class:`HostConnectError` with a user-facing message
when *exc* is non-retryable, or ``None`` when the caller
should treat *exc* as transient and reconnect.
"""
if isinstance(exc, InvalidURI):
# websockets followed a redirect whose Location wasn't ws/wss —
# the Apps OAuth proxy bounced the upgrade to a login page. This
# also happens transiently during server restarts (the proxy
# redirects before the app is ready), so a host that has already
# connected retries forever, while a host that never
# authenticated gets a few retries to rule out a blip and then
# fails loud instead of looping silently while the
# only diagnostics land in the log file.
self._login_redirect_streak += 1
cause = (
"Authentication failed: the server redirected the host "
"tunnel to a login page instead of accepting it, so no "
"session was established."
)
if (
not self._ever_connected
and self._login_redirect_streak >= _LOGIN_REDIRECT_FATAL_ATTEMPTS
):
return HostConnectError(
f"{cause} The redirect persisted across "
f"{self._login_redirect_streak} attempts. "
+ self._credentials_fix_hint()
+ " (If the server is mid-restart, wait a minute and retry.)"
)
_logger.warning("%s %s", cause, self._credentials_fix_hint())
if self._login_redirect_streak == 1:
# The warning above lands in the CLI log file, not the
# terminal — print once per redirect streak so a foreground
# `omnigent host` shows the auth problem and its fix instead
# of sitting silent while it retries.
print(
f"⚠ {cause} Retrying — this also happens briefly while "
f"the server restarts. {self._credentials_fix_hint()}",
file=sys.stderr,
flush=True,
)
return None
return self._classify_http_status(exc.response.status_code)
def _classify_http_status(self, status: int) -> HostConnectError | None:
"""Map a rejected-upgrade HTTP status to a fatal error, or ``None``.
:param status: HTTP status on the failed WS upgrade response, e.g.
``403``.
:returns: A :class:`HostConnectError` for a permanent 4xx, or
``None`` for a transient status (retryable 4xx in
:data:`_RETRYABLE_UPGRADE_STATUSES`, or any non-4xx such as a
5xx server bounce) that the reconnect loop should retry.
"""
if status in _RETRYABLE_UPGRADE_STATUSES or not (400 <= status < 500):
return None
if status == 401:
return HostConnectError(
"Authentication failed (HTTP 401): the server rejected the "
"supplied credentials. "
+ self._credentials_fix_hint()
+ " "
+ self._login_fix_hint()
)
if status == 403:
return HostConnectError(
"Connection refused (HTTP 403): the credentials authenticated, "
"but the server did not accept the host tunnel. Either your "
"identity is not authorized to register a host on this server, "
"or the server is running a build that predates the host API "
"(the /v1/hosts tunnel route). Confirm you have access and that "
"the server is up to date, then retry. " + self._login_fix_hint()
)
if status == 409:
return HostConnectError(
"Connection refused (HTTP 409): this machine is already "
"registered to a different account on this server, so the "
"account you authenticated as cannot claim it. This usually "
"means the host was first registered under another identity "
"(e.g. the single-user 'local' owner before the server "
"switched to accounts auth). Ask an administrator to remove "
"the existing host registration, or reset this machine's host "
"id, then retry. " + self._login_fix_hint()
)
return HostConnectError(
f"Connection refused (HTTP {status}): the server rejected the host "
"tunnel request. This is a permanent error; retrying will not help. "
"Check the server URL and your access."
)
async def _handle_launch(
self,
frame: HostLaunchRunnerFrame,
) -> HostLaunchRunnerResultFrame:
"""Handle a launch_runner request from the server.
Spawns a runner subprocess with the binding token and
workspace from the frame, after verifying the session's
harness (when the frame carries one) is configured on this
machine.
:param frame: The launch request frame.
:returns: Result frame with status and runner_id, or a
``"failed"`` result with ``error_code`` set to
``"harness_not_configured"`` when the harness check
refuses the launch.
"""
# Refuse to spawn for a harness this machine can't actually run —
# otherwise the runner starts, the session looks alive, and the
# first turn dies confusingly inside the executor. ``None`` (an
# older server, or a session with no resolvable harness) skips the
# check so version skew fails open.
if frame.harness is not None and not harness_is_configured(frame.harness):
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
status="failed",
error=(
f"harness {frame.harness!r} is not configured on host "
f"{self._identity.name!r}{harness_setup_hint(frame.harness)}"
),
error_code=HARNESS_NOT_CONFIGURED_ERROR_CODE,
)
workspace = Path(frame.workspace).expanduser()
if not workspace.is_dir():
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
status="failed",
error=f"workspace path does not exist: {workspace}",
)
runner_id = token_bound_runner_id(frame.binding_token)
env = _build_runner_env(
os.environ,
server_url=self._server_url,
runner_id=runner_id,
binding_token=frame.binding_token,
workspace=str(workspace),
parent_pid=os.getpid(),
)
try:
log_dir = _runner_log_dir()
log_dir.mkdir(parents=True, exist_ok=True)
# Embed the session id so operators can find all logs for a
# session with `omnigent debug logs --session <id>`. Cap at 32
# chars to keep filenames manageable; strip anything non-word to
# guard against unexpected id shapes from older servers.
import re
import tempfile
_session_slug = (
re.sub(r"[^\w-]", "", frame.session_id)[:32] + "-" if frame.session_id else ""
)
_log_fd, _log_name = tempfile.mkstemp(
prefix=f"runner-{_session_slug}",
suffix=".log",
dir=log_dir,
)
_log_fh = os.fdopen(_log_fd, "wb")
proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
env=env,
# Runners are WS-tunnel clients with no interactive input.
# Give them a clean /dev/null stdin instead of inheriting the
# daemon's: a long-lived daemon (e.g. backgrounded / nohup'd)
# can end up with a closed or recycled stdin fd, and an
# inherited bad fd makes the runner die at interpreter startup
# with "init_sys_streams: Bad file descriptor" — it never
# connects, so the session fails with "runner did not connect".
stdin=subprocess.DEVNULL,
stdout=_log_fh,
stderr=_log_fh,
)
_log_fh.close()
except OSError as exc:
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
status="failed",
error=f"failed to spawn runner: {exc}",
)
log_path = Path(_log_name)
if proc.poll() is not None:
# The runner died before Popen returned — its actual error
# is in the captured log, so ship the tail with the result
# instead of making the user go find the file on the host.
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
status="failed",
error=_runner_exit_error(proc.returncode, log_path),
)
self._runners[runner_id] = _RunnerHandle(proc=proc, log_path=log_path)
watcher = asyncio.create_task(self._watch_runner(runner_id))
self._watcher_tasks.add(watcher)
watcher.add_done_callback(self._watcher_tasks.discard)
_logger.info(
"Launched runner %s for workspace %s (pid=%d)",
runner_id,
workspace,
proc.pid,
)
# Print the exact runner log file (not just the dir): a foreground
# host's own terminal shows lifecycle lines, but the runner's real
# output — the agent turn, tracebacks — lands only in this file.
session_line = f"\n session: {frame.session_id}" if frame.session_id else ""
print(
f" ↑ Runner started: {runner_id} (pid={proc.pid})\n"
f" log: {_display_log_path(log_path)}"
f"{session_line}",
flush=True,
)
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
status="launched",
runner_id=runner_id,
)
def _handle_stop(
self,
frame: HostStopRunnerFrame,
) -> HostStopRunnerResultFrame:
"""Handle a stop_runner request from the server.
Terminates the runner subprocess if it exists.
:param frame: The stop request frame.
:returns: Result frame with status.
"""
handle = self._runners.pop(frame.runner_id, None)
if handle is None:
return HostStopRunnerResultFrame(
request_id=frame.request_id,
status="failed",
error=f"unknown runner: {frame.runner_id}",
)
if handle.proc.poll() is None:
handle.proc.terminate()
try:
handle.proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
handle.proc.kill()
handle.proc.wait()
_logger.info("Stopped runner %s", frame.runner_id)
print(
f" ↓ Runner stopped: {frame.runner_id}",
flush=True,
)
return HostStopRunnerResultFrame(
request_id=frame.request_id,
status="stopped",
)
async def _watch_runner(self, runner_id: str) -> None:
"""Watch a spawned runner and report an unexpected exit.
Polls the runner subprocess until it exits. An exit while the
runner is still tracked in ``self._runners`` is unexpected (a
``host.stop_runner`` pops the entry *before* terminating), so
the watcher composes the exit error — code plus log tail — and
reports it to the server via ``host.runner_exited``. Without
this, a runner that crashes before connecting its tunnel
(auth rejection, bad env, import error) leaves the client
polling to a timeout with the cause stranded in a log file on
this host.
:param runner_id: The runner to watch, e.g.
``"runner_abc123..."``.
:returns: None. Returns silently for intentional stops.
"""
handle = self._runners.get(runner_id)
if handle is None: # pragma: no cover — spawned just before us
return
while handle.proc.poll() is None:
await asyncio.sleep(_RUNNER_WATCH_INTERVAL_S)
if self._runners.get(runner_id) is not handle:
# _handle_stop (or _cleanup_runners) removed it first —
# an intentional termination, not a crash to report.
return
error = _runner_exit_error(handle.proc.returncode, handle.log_path)
_logger.warning("Runner %s died unexpectedly: %s", runner_id, error)
await self._report_runner_exit(runner_id, error)
async def _report_runner_exit(self, runner_id: str, error: str) -> None:
"""Send a ``host.runner_exited`` report, queueing on failure.
:param runner_id: The dead runner, e.g. ``"runner_abc123..."``.
:param error: Composed exit error from
:func:`_runner_exit_error`.
:returns: None. A report that cannot be sent (tunnel down or
mid-reconnect) is parked in ``self._unreported_exits`` and
flushed by :meth:`_serve_frames` after the next hello.
"""
frame = encode_host_frame(HostRunnerExitedFrame(runner_id=runner_id, error=error))
ws = self._ws
if ws is not None:
try:
await ws.send(frame)
return
except Exception: # noqa: BLE001 — any send failure parks the report
_logger.debug(
"Could not send runner_exited for %s; queueing for reconnect",
runner_id,
exc_info=True,
)
self._unreported_exits[runner_id] = error
def _handle_stat(self, frame: HostStatFrame) -> HostStatResultFrame:
"""Handle a ``host.stat`` request from the server.
Expands ``~`` against the host process owner's home (the
host is the source of truth for ``~`` — the server never
does this), follows symlinks via ``os.stat``, computes the
canonical realpath, and collapses ENOENT + EACCES into
``exists: false``. Unexpected I/O errors return ``status:
"failed"``. See designs/SESSION_WORKSPACE_SELECTION.md.
:param frame: The stat request frame. ``frame.path`` may
be a fully absolute path or a tilde-prefixed path.
:returns: Stat result frame with ``exists``, ``type``, and
``canonical_path`` populated when the path is reachable.
"""
try:
expanded = os.path.expanduser(frame.path)
except (TypeError, ValueError) as exc:
# Defensive: expanduser shouldn't raise on str inputs,
# but a malformed path could in principle. Fail loud
# with a useful message rather than letting a generic
# error bubble up to the server.
return HostStatResultFrame(
request_id=frame.request_id,
status="failed",
exists=False,
error=f"path expansion failed: {exc}",
)
try:
# ``os.stat`` follows symlinks by default — exactly
# what the design wants ("type reflects the target").
st = os.stat(expanded)
except (FileNotFoundError, PermissionError):
# ENOENT and EACCES collapse to "exists: false" so the
# server validation has a single contract for "not
# reachable."
return HostStatResultFrame(
request_id=frame.request_id,
status="ok",
exists=False,
)
except OSError as exc:
return HostStatResultFrame(
request_id=frame.request_id,
status="failed",
exists=False,
error=f"stat failed: {exc.strerror or str(exc)}",
)
try:
canonical = os.path.realpath(expanded)
except OSError as exc:
return HostStatResultFrame(
request_id=frame.request_id,
status="failed",
exists=False,
error=f"realpath failed: {exc.strerror or str(exc)}",
)
from stat import S_ISDIR, S_ISREG
if S_ISDIR(st.st_mode):
entry_type = "directory"
elif S_ISREG(st.st_mode):
entry_type = "file"
else:
entry_type = "other"
return HostStatResultFrame(
request_id=frame.request_id,
status="ok",
exists=True,
type=entry_type,
canonical_path=canonical,
)
def _handle_list_dir(self, frame: HostListDirFrame) -> HostListDirResultFrame:
"""Handle a ``host.list_dir`` request from the server.
Walks the requested directory with ``os.scandir``, follows
symlinks for type detection (matching ``host.stat``), and
returns a paginated result. ``~`` in the input path expands
against the host process owner's home, same rules as
``host.stat``. Per-entry I/O errors (broken symlinks,
ephemeral files) are silently skipped so a single bad
entry doesn't fail the whole listing — same posture as
the runner's ``list_dir``.
:param frame: The list_dir request frame. ``frame.path``
may be absolute or tilde-prefixed; ``limit`` /
``after`` / ``before`` drive pagination.
:returns: List_dir result frame with entries sorted by
name plus a ``has_more`` flag for the page.
"""
try:
expanded = os.path.expanduser(frame.path)
except (TypeError, ValueError) as exc:
return HostListDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"path expansion failed: {exc}",
)
try:
scanned = list(os.scandir(expanded))
except FileNotFoundError:
return HostListDirResultFrame(
request_id=frame.request_id,
status="ok",
error="path does not exist",
)
except NotADirectoryError:
return HostListDirResultFrame(
request_id=frame.request_id,
status="ok",
error="path is not a directory",
)
except PermissionError:
return HostListDirResultFrame(
request_id=frame.request_id,
status="ok",
error="permission denied",
)
except OSError as exc:
return HostListDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"scandir failed: {exc.strerror or str(exc)}",
)
from stat import S_ISDIR, S_ISREG
# Walk every entry, classifying by target type. Per-entry
# OSError → skip (e.g. dangling symlink) so the listing
# surfaces real entries instead of failing wholesale.
entries: list[HostListDirEntry] = []
for de in scanned:
try:
# follow_symlinks=True so type reflects the target.
st = de.stat(follow_symlinks=True)
except OSError:
continue
if S_ISDIR(st.st_mode):
entry_type = "directory"
size: int | None = None
elif S_ISREG(st.st_mode):
entry_type = "file"
size = st.st_size
else:
entry_type = "other"
size = None
entries.append(
HostListDirEntry(
name=de.name,
path=de.path,
type=entry_type,
bytes=size,
modified_at=int(st.st_mtime),
)
)
# Sort by name for stable pagination cursors. Cursors are
# entry paths so they survive concurrent directory writes
# better than an in-memory index.
entries.sort(key=lambda e: e.name)
return _paginate_list_dir(
entries=entries,
request_id=frame.request_id,
limit=frame.limit,
after=frame.after,
before=frame.before,
)
def _handle_create_dir(self, frame: HostCreateDirFrame) -> HostCreateDirResultFrame:
"""Handle a ``host.create_dir`` request from the server.
Creates the directory (and any missing parents) with
``os.makedirs``. ``~`` expands against the host process
owner's home, same rules as ``host.list_dir``. Expected
filesystem errors (the directory already exists, permission
denied, a parent component is a file) return ``status: "ok"``
with a descriptive ``error`` so the route layer can map them
to a 409 rather than a 500 — mirroring how ``_handle_list_dir``
reports a missing path. Only unexpected I/O errors surface as
``status: "failed"``.
:param frame: The create-dir request frame. ``frame.path`` may
be absolute or tilde-prefixed.
:returns: Result frame carrying the created absolute path on
success, or an ``error`` describing why it was not created.
"""
try:
expanded = os.path.expanduser(frame.path)
except (TypeError, ValueError) as exc:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"path expansion failed: {exc}",
)
try:
# exist_ok=False so creating an existing folder is a clear
# "already exists" rather than a silent no-op — the picker
# should tell the user the name is taken.
os.makedirs(expanded, exist_ok=False)
except FileExistsError:
# makedirs raises FileExistsError whether the leaf is an
# existing directory or a regular file. Distinguish the two
# so "name is taken by a file" isn't mislabelled as an
# existing directory.
error = (
"directory already exists"
if os.path.isdir(expanded)
else "a file already exists at that path"
)
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error=error,
)
except NotADirectoryError:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error="a parent path component is not a directory",
)
except PermissionError:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error="permission denied",
)
except OSError as exc:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"mkdir failed: {exc.strerror or str(exc)}",
)
created = os.path.abspath(expanded)
_logger.info("Created directory %s", created)
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
path=created,
)
async def _handle_create_worktree(
self,
frame: HostCreateWorktreeFrame,
) -> HostCreateWorktreeResultFrame:
"""Handle a ``host.create_worktree`` request from the server.
Runs the blocking git work in a worker thread so the tunnel
loop keeps servicing pings. See designs/SESSION_GIT_WORKTREE.md.
:param frame: The create-worktree request frame.
:returns: Result frame with the worktree path and branch on
success, or ``status: "failed"`` with an error message.
"""
try:
# Pause the orphan reaper: create_worktree runs git via
# subprocess.run, whose children are direct children of this host
# but not tracked runners — the reaper must not wait() them out
# from under subprocess (#1782).
with self._host_subprocess_op():
created = await asyncio.to_thread(
create_worktree,
repo_path=frame.repo_path,
branch_name=frame.branch_name,
base_branch=frame.base_branch,
)
except WorktreeError as exc:
return HostCreateWorktreeResultFrame(
request_id=frame.request_id,
status="failed",
error=exc.message,
)
_logger.info(
"Created worktree %s (branch %s) from %s",
created.worktree_path,
created.branch,
frame.repo_path,
)
return HostCreateWorktreeResultFrame(
request_id=frame.request_id,
status="ok",
worktree_path=created.worktree_path,
branch=created.branch,
)
async def _handle_remove_worktree(
self,
frame: HostRemoveWorktreeFrame,
) -> HostRemoveWorktreeResultFrame:
"""Handle a ``host.remove_worktree`` request from the server.
Runs the blocking git work in a worker thread.
:param frame: The remove-worktree request frame.
:returns: Result frame with ``status: "ok"`` on success, or
``status: "failed"`` with an error message.
"""
try:
# Pause the orphan reaper while remove_worktree runs git — see
# _handle_create_worktree above and _reap_orphans_once (#1782).
with self._host_subprocess_op():
await asyncio.to_thread(
remove_worktree,
worktree_path=frame.worktree_path,
branch=frame.branch,
delete_branch=frame.delete_branch,
)
except WorktreeError as exc:
return HostRemoveWorktreeResultFrame(
request_id=frame.request_id,
status="failed",
error=exc.message,
)
_logger.info(
"Removed worktree %s (delete_branch=%s, branch=%s)",
frame.worktree_path,
frame.delete_branch,
frame.branch,
)
return HostRemoveWorktreeResultFrame(
request_id=frame.request_id,
status="ok",
)
async def _handle_list_worktrees(
self,
frame: HostListWorktreesFrame,
) -> HostListWorktreesResultFrame:
"""Handle a ``host.list_worktrees`` request from the server.
Runs the blocking git work in a worker thread so the tunnel
loop keeps servicing pings.
:param frame: The list-worktrees request frame.
:returns: Result frame with the worktrees on success, or
``status: "failed"`` with an error message.
"""
try:
# Pause the orphan reaper while git runs — see
# _handle_create_worktree above and _reap_orphans_once.
with self._host_subprocess_op():
worktrees = await asyncio.to_thread(
list_worktrees,
repo_path=frame.repo_path,
)
except WorktreeError as exc:
return HostListWorktreesResultFrame(
request_id=frame.request_id,
status="failed",
error=exc.message,
)
return HostListWorktreesResultFrame(
request_id=frame.request_id,
status="ok",
worktrees=[
{
"path": wt.path,
"branch": wt.branch,
"is_main": wt.is_main,
"detached": wt.detached,
}
for wt in worktrees
],
)
async def run(self) -> None:
"""Run the host process with reconnection.
Connects to the server, sends hello, and enters the
receive loop. Reconnects with exponential backoff on
disconnect. Ctrl-C / SIGTERM exit cleanly.
:returns: None. Runs until the process is terminated.
"""
# Reap orphaned harness/tool grandchildren that reparent here when a
# runner dies (this host is PID 1 in a container, or a subreaper
# otherwise). Without this they pile up as <defunct> zombies and can
# OOM the box on a long-blocked run (#1782).
if _install_child_subreaper():
_logger.debug("installed PR_SET_CHILD_SUBREAPER; host will reap orphans")
self._reaper_task = asyncio.create_task(
self._orphan_reaper_loop(), name="host-orphan-reaper"
)
backoff = _RECONNECT_BASE_S
try:
while True:
try:
await self._connect_and_serve()
backoff = _RECONNECT_BASE_S
except (KeyboardInterrupt, asyncio.CancelledError):
break
except HostConnectError:
# Permanent failure (auth / authorization / outdated
# server). Do NOT back off and retry — propagate so
# ``run_host_process`` can fail loud.
raise
except Exception as exc: # noqa: BLE001 — reconnect loop
if not isinstance(exc, InvalidURI):
# Any non-redirect failure (5xx bounce, network
# blip, mid-serve drop) breaks a login-redirect
# streak — _login_redirect_streak counts
# CONSECUTIVE redirects only, so a fresh host
# riding out a messy restart isn't killed by
# redirects accumulated across unrelated errors.
self._login_redirect_streak = 0
# Classify the disconnect to choose a reconnect cadence.
#
# 1012 "service restart" / 1001 "going away" are explicit
# close codes a server (or a graceful Apps recycle) sends —
# always a prompt reconnect.
#
# An abrupt "no close frame" / 502 is, on a REMOTE server,
# the Databricks Apps ingress cycling a long-lived WebSocket
# out from under a healthy app — also a prompt reconnect, so
# the host tunnel isn't down long enough to drop a
# launch_runner frame ("runner did not connect").
#
# But on a LOOPBACK server there is no Apps ingress — an
# abrupt drop is a real condition (the server closed our
# tunnel, e.g. a re-registration of the same host_id). Fast
# 0.5s reconnects there *fuel* a re-registration flap: the
# next connect overlaps the previous teardown, the server
# drops a duplicate, repeat. Back off normally on loopback
# so the overlap window closes and the tunnel settles (and a
# genuinely persistent failure surfaces instead of a silent
# tight loop).
reason = str(exc).lower()
explicit_recycle = any(
t in reason for t in ("1012", "service restart", "1001", "going away")
)
ingress_recycle = any(t in reason for t in ("no close frame", "502"))
recycle = explicit_recycle or (
ingress_recycle and not _url_is_loopback(self._server_url)
)
wait_s = _RECONNECT_BASE_S if recycle else backoff
_logger.warning(
"Host tunnel disconnected: %s. Reconnecting in %.1fs%s",
exc,
wait_s,
" (recycle — prompt reconnect)" if recycle else "",
)
await asyncio.sleep(wait_s)
import random
if recycle:
backoff = _RECONNECT_BASE_S
else:
backoff = min(
backoff * 2 * (1 + random.random() * _RECONNECT_JITTER),
_RECONNECT_CAP_S,
)
except (KeyboardInterrupt, asyncio.CancelledError):
pass
finally:
if self._reaper_task is not None:
self._reaper_task.cancel()
self._reaper_task = None
self._cleanup_runners()
# Final drain: _cleanup_runners has just reaped the tracked
# runners via Popen, so any of their still-orphaned tool
# grandchildren are now reapable and no tracked pid can be stolen.
self._reap_orphans_once()
def _cleanup_runners(self) -> None:
"""Terminate all live runners on shutdown.
:returns: None.
"""
for runner_id, handle in self._runners.items():
if handle.proc.poll() is None:
_logger.info("Terminating runner %s on shutdown", runner_id)
handle.proc.terminate()
for handle in self._runners.values():
try:
handle.proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
handle.proc.kill()
self._runners.clear()
async def _connect_and_serve(self) -> None:
"""Single connection attempt: connect, hello, serve.
:returns: None.
:raises Exception: On WebSocket disconnect or error.
"""
url = self._tunnel_url()
headers = self._build_connect_headers()
_logger.info("Connecting to %s", url)
try:
ws_cm = websockets.asyncio.client.connect(
url,
additional_headers=headers,
max_size=100 * 1024 * 1024,
# Align the host->server tunnel's protocol keepalive to the same
# 90 s app-level budget as the runner tunnel (not the 20 s library
# default that drops a busy-but-healthy tunnel with 1011 — #1116).
# Symmetric with serve.py's runner-side connect().
ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
)
ws = await ws_cm.__aenter__()
except (InvalidURI, InvalidStatus) as exc:
# The upgrade itself was rejected. Fail loud on permanent
# failures (auth / authorization / outdated server); let the
# reconnect loop retry transient ones.
fatal = self._fatal_upgrade_error(exc)
if fatal is not None:
raise fatal from exc
raise
# An accepted upgrade proves the credentials work: login redirects
# from here on are server restarts, not an unauthenticated host.
self._ever_connected = True
self._login_redirect_streak = 0
try:
await self._serve_frames(ws)
finally:
# Drop the watcher tasks' send target — exit reports raised
# between connections park in _unreported_exits instead of
# racing a half-closed socket.
self._ws = None
# Close the tunnel context whether the serve loop returned
# normally or raised (disconnect → reconnect). Mirrors the
# ``async with`` this replaced; the manual enter is only so the
# upgrade-time exception can be classified above.
await ws_cm.__aexit__(*sys.exc_info())
def _build_connect_headers(self) -> dict[str, str]:
"""Build the WebSocket upgrade headers for the tunnel connection.
Server-managed sandbox hosts authenticate with the launch token
the server injected at spawn (:data:`HOST_TOKEN_ENV_VAR`); when
present it is sent on its dedicated header and the user-token
path is skipped entirely (a sandbox has no user credentials).
Otherwise mints a fresh Databricks bearer token via the runner's
auth factory (refreshed every reconnect so long-lived hosts
survive token expiry). Token acquisition failures are swallowed —
the upgrade proceeds unauthenticated and the server/proxy
decides.
:returns: Header mapping for the WS upgrade; carries either the
managed-host token header or — only when a token could be
minted — ``{"Authorization": "Bearer <token>"}``.
"""
from omnigent.host.identity import HOST_TOKEN_ENV_VAR, MANAGED_HOST_TOKEN_HEADER
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
# Identify as a first-party client so the server's WebSocket origin
# guard (CSWSH protection) allows the handshake — the host process
# is not a browser. Seeded before either auth branch so it is sent
# on both the managed-token and Bearer paths.
headers: dict[str, str] = {"Origin": OMNIGENT_INTERNAL_WS_ORIGIN}
# Workspace routing: the tunnel handshake must name the workspace or
# it routes to the account. Empty for single-workspace and managed
# hosts (no recorded selector), so neither is affected.
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(self._server_url))
managed_token = os.environ.get(HOST_TOKEN_ENV_VAR)
if managed_token:
headers[MANAGED_HOST_TOKEN_HEADER] = managed_token
return headers
try:
from omnigent.runner._entry import _make_auth_token_factory
# Pass server_url explicitly. The factory's OIDC-token path
# would otherwise look up ``RUNNER_SERVER_URL`` from env,
# which only the runner subprocess sets — without it the
# stored ``omnigent login`` token is silently skipped and
# the factory falls through to the Databricks path.
factory = _make_auth_token_factory(server_url=self._server_url)
token = factory() if factory else None
if token:
headers["Authorization"] = f"Bearer {token}"
except Exception: # noqa: BLE001
_logger.debug("Could not obtain auth token", exc_info=True)
return headers
async def _serve_frames(self, ws: websockets.asyncio.client.ClientConnection) -> None:
"""Announce readiness, then service host frames until disconnect.
Sends the ``host.hello`` frame, prints the success banner, then
loops dispatching launch/stop/stat/list_dir/worktree requests and
answering runner pings until the connection closes.
:param ws: The open tunnel connection returned by the websockets
client.
:returns: None. Returns when the receive loop is broken.
:raises Exception: On WebSocket disconnect or error — propagated
to the reconnect loop in :meth:`run`.
"""
hello = HostHelloFrame(
version=VERSION,
frame_protocol_version=1,
name=self._identity.name,
runners=self._alive_runner_ids(),
# Off the event loop: probes PATH (shutil.which) and reads
# ~/.omnigent/config.yaml. Recomputed on every (re)connect, so
# the server's view refreshes whenever the tunnel does; the
# launch-time check above stays the authoritative gate.
configured_harnesses=await asyncio.to_thread(configured_harness_map),
)
await ws.send(encode_host_frame(hello))
self._ws = ws
# Flush exit reports that raced a disconnect: a runner that died
# while the tunnel was down would otherwise never be reported and
# the waiting client would poll to its timeout.
for runner_id, error in list(self._unreported_exits.items()):
del self._unreported_exits[runner_id]
await self._report_runner_exit(runner_id, error)
# ``print`` (not ``_logger.warning``) so the user always sees the
# success line after the noisy ``databricks.sdk`` warnings —
# otherwise the terminal goes silent after auth and there's no
# signal the WS handshake actually completed.
print(
f"✓ Connected as {self._identity.name!r} "
f"({self._identity.host_id}), {len(hello.runners)} live runner(s). "
"Listening for sessions — Ctrl-C to disconnect.",
flush=True,
)
while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=60.0)
except asyncio.TimeoutError:
continue
if isinstance(raw, str):
await self._handle_raw_message(ws, raw)
async def _handle_raw_message(
self, ws: websockets.asyncio.client.ClientConnection, raw: str
) -> None:
"""Decode one inbound text frame and route it to a handler.
Host frames go to :meth:`_dispatch_host_frame`; a runner
``ping`` is answered with a ``pong`` inline; anything that
decodes as neither is ignored (forward-compatible with frame
types this host version doesn't know).
:param ws: The open tunnel connection, used to send replies.
:param raw: The raw text frame received off the socket.
:returns: None.
"""
try:
frame = decode_host_frame(raw)
except ValueError:
# Not a host frame — it may be a runner ping (the tunnel
# multiplexes both frame families over one socket).
try:
runner_frame = decode_frame(raw)
except ValueError:
return
if isinstance(runner_frame, PingFrame):
await ws.send(encode_frame(PongFrame(ts=runner_frame.ts)))
return
# Handle the frame inside a CONSUMER span parented on the trace
# context the server stamped into the frame envelope, so the
# host's work (and the result frame it sends back) nests under
# the server request that triggered it.
from omnigent.runtime import telemetry
try:
carrier = json.loads(raw)
except ValueError:
carrier = {}
if not isinstance(carrier, dict):
carrier = {}
raw_kind = carrier.get("kind")
kind = raw_kind if isinstance(raw_kind, str) else type(frame).__name__
with telemetry.consume_frame_span(kind, carrier):
await self._dispatch_host_frame(ws, frame)
async def _dispatch_host_frame(
self,
ws: websockets.asyncio.client.ClientConnection,
frame: object,
) -> None:
"""Handle a decoded host frame and send its result back.
:param ws: The open tunnel connection, used to send the result.
:param frame: A decoded host frame (one of the
``Host*Frame`` request types); unrecognized frame types are
ignored.
:returns: None.
"""
if isinstance(frame, HostLaunchRunnerFrame):
await ws.send(encode_host_frame(await self._handle_launch(frame)))
elif isinstance(frame, HostStopRunnerFrame):
await ws.send(encode_host_frame(self._handle_stop(frame)))
elif isinstance(frame, HostStatFrame):
await ws.send(encode_host_frame(self._handle_stat(frame)))
elif isinstance(frame, HostListDirFrame):
await ws.send(encode_host_frame(self._handle_list_dir(frame)))
elif isinstance(frame, HostCreateDirFrame):
await ws.send(encode_host_frame(self._handle_create_dir(frame)))
elif isinstance(frame, HostCreateWorktreeFrame):
await ws.send(encode_host_frame(await self._handle_create_worktree(frame)))
elif isinstance(frame, HostRemoveWorktreeFrame):
await ws.send(encode_host_frame(await self._handle_remove_worktree(frame)))
elif isinstance(frame, HostListWorktreesFrame):
await ws.send(encode_host_frame(await self._handle_list_worktrees(frame)))
def run_host_process(
server_url: str,
config_path: Path | None = None,
) -> None:
"""Entry point for ``omnigent host``.
Loads (or creates) the host identity from the ``host`` section
of ``~/.omnigent/config.yaml``, then runs the host process.
:param server_url: Server URL to connect to, e.g.
``"https://omnigent-app.databricksapps.com"``.
:param config_path: Optional path to ``config.yaml``.
Defaults to ``~/.omnigent/config.yaml``.
:raises SystemExit: With code 1 when the tunnel fails permanently
(auth / authorization / outdated server). The
actionable cause is printed to stderr first.
"""
# Initialize tracing so the host daemon exports its own spans
# (e.g. handling launch_runner / stat / list_dir frames) into the
# same distributed trace as the server that requested them. The
# daemon inherits OTEL_*/MLFLOW_* config from the launching CLI.
from omnigent.runtime import telemetry
telemetry.init("omni-host")
from omnigent.host.identity import CONFIG_PATH
path = config_path or CONFIG_PATH
identity = load_or_create_host_identity(path)
if not path.exists():
print(f"Auto-generated {path} ({identity.host_id}, name: {identity.name})")
print(f"Connecting to {server_url} as {identity.name!r} ({identity.host_id})")
# Tell the user where logs land up front — `omnigent host` used to run
# silently, so a stuck/quiet host gave no hint where to look. Session
# work goes to per-runner files under the host-runner dir (the exact
# file is printed when each runner launches). The foreground process's
# own diagnostics (warnings, tracebacks) go to the always-on cli-*.log;
# that path is None in the background daemon (no setup_cli_logging) —
# its stdout is already captured to the daemon log, so skip the line.
print(f"Session logs: {_display_log_path(_runner_log_dir())}/")
from omnigent.cli_diagnostics import current_cli_log_path
_cli_log = current_cli_log_path()
if _cli_log is not None:
print(f"This host's log: {_display_log_path(_cli_log)}")
host = HostProcess(identity, server_url)
try:
asyncio.run(host.run())
except HostConnectError as exc:
# Fail loud: a permanent connection failure must not look like the
# process is still working. Print the cause + fix, then exit non-zero
# instead of the old behavior of reconnecting silently forever.
print(f"\n✗ Could not connect to {server_url}.\n{exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc