"""Persistent dashboard compute-host process. Phase 0 used this module as a deterministic line-JSON spike. Phase 1 keeps the same transport and turns it into the long-lived child that owns live AIAgent objects when ``dashboard.turn_isolation`` is enabled. """ from __future__ import annotations import argparse import concurrent.futures import contextlib import json import os import signal import subprocess import sys import threading import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Collection from agent.interrupt_compat import request_hard_interrupt from tui_gateway.host_supervisor import MUTATOR_ROUTE_TABLE, _build_sha def now_ns() -> int: return time.perf_counter_ns() @dataclass class SpikeAgent: """A deterministic AIAgent-shaped object for pipe/interrupt measurements.""" session_id: str history: list[dict[str, str]] = field(default_factory=list) _interrupt: threading.Event = field(default_factory=threading.Event) def clear_interrupt(self) -> None: self._interrupt.clear() def interrupt(self, *, hard_cancel: bool = False) -> None: self._interrupt.set() def run_conversation( self, prompt: str, *, conversation_history: list[dict[str, str]] | None = None, stream_callback: Callable[[str], None] | None = None, delta_count: int = 24, delay_s: float = 0.001, ) -> dict[str, Any]: base_history = list(conversation_history if conversation_history is not None else self.history) chunks: list[str] = [] interrupted = False for index in range(max(0, int(delta_count))): if self._interrupt.is_set(): interrupted = True break chunk = f"{self.session_id}:{prompt}:{index:04d} " chunks.append(chunk) if stream_callback is not None: stream_callback(chunk) if delay_s > 0: time.sleep(delay_s) if self._interrupt.is_set(): interrupted = True final = "".join(chunks) if interrupted: final += "[interrupted]" messages = [*base_history, {"role": "user", "content": prompt}, {"role": "assistant", "content": final}] self.history = messages return {"final_response": final, "messages": messages, "interrupted": interrupted} @dataclass class HostSession: sid: str agent: SpikeAgent history_version: int = 0 running: bool = False lock: threading.Lock = field(default_factory=threading.Lock) class _HostTransport: def __init__(self, emit: Callable[[dict[str, Any]], None]) -> None: self._emit = emit def write(self, obj: dict) -> bool: sid = "" with contextlib.suppress(Exception): if obj.get("method") == "event": sid = str(((obj.get("params") or {}).get("session_id")) or "") self._emit({"type": "rpc", "sid": sid, "message": obj}) return True def close(self) -> None: return None # Slice of ``ComputeHost.shutdown``'s budget held back for the post-drain # finalize. ``HostSupervisor._terminate_pid`` SIGKILLs the host # ``_SHUTDOWN_TIMEOUT_SECS`` (10s, same as ``shutdown``'s default ``wait``) # after SIGTERM, so a drain allowed to consume the whole budget would leave the # flush racing that kill and persist nothing at all. _FLUSH_RESERVE_SECS = 1.0 class ComputeHost: # frame ``type`` -> handler method name (resolved per call so instance # monkeypatches of a handler still take effect). _FRAME_HANDLERS: dict[str, str] = { "session.seed": "_handle_seed", "turn.start": "_handle_turn_start", "interrupt": "_handle_interrupt", "respond": "_handle_respond", "reload_mcp": "_handle_reload_mcp", "control": "_handle_control", "shutdown": "_handle_shutdown", } def __init__( self, *, stdout: Any = None, max_workers: int | None = None, heartbeat_secs: int | float | None = None, ) -> None: self._stdout = stdout or sys.stdout self._write_lock = threading.Lock() self._sessions: dict[str, HostSession] = {} self._executor = concurrent.futures.ThreadPoolExecutor( max_workers=max_workers or _default_workers(), thread_name_prefix="compute-host-turn", ) self._closed = threading.Event() self._parent_pid = os.getppid() self._boot_id = uuid.uuid4().hex self._progress_counter = 0 self._progress_lock = threading.Lock() # Future -> the ``sid`` whose turn it is running. ``shutdown`` needs to # know *whose* turn is still live so it can leave those sessions # unfinalized; a bare set cannot answer that. self._turn_futures: dict[concurrent.futures.Future, str] = {} self._turn_futures_lock = threading.Lock() self._transport = _HostTransport(self.emit) self._heartbeat_secs = ( float(heartbeat_secs) if heartbeat_secs is not None else float(os.environ.get("HERMES_COMPUTE_HOST_HEARTBEAT_SECS") or "15") ) if self._heartbeat_secs > 0: threading.Thread(target=self._heartbeat_loop, name="compute-host-heartbeat", daemon=True).start() threading.Thread(target=self._parent_guard_loop, name="compute-host-ppid-guard", daemon=True).start() def emit(self, frame: dict[str, Any]) -> None: frame.setdefault("host_ns", now_ns()) data = json.dumps(frame, separators=(",", ":"), ensure_ascii=False) with self._write_lock: print(data, file=self._stdout, flush=True) def close(self) -> None: self._closed.set() self._executor.shutdown(wait=False, cancel_futures=True) def shutdown(self, *, reason: str = "shutdown", wait: float = 10.0) -> None: """Drain in-flight turns, then finalize every session. Order matters: ``_finalize_session`` is a one-shot latch (sets ``session["_finalized"]``), so finalizing before the drain would spend the flush's single chance while turns were still producing output, fire ``on_session_end(interrupted=True)`` against a running session and release the active-session lease under a live turn. ``_FLUSH_RESERVE_SECS`` of the budget (never more than half, so a short explicit ``wait`` still gets a real drain) is withheld from the drain so the flush still runs when in-flight turns outlast the window; ``wait`` itself is unchanged, so no added latency or kill-escalation exposure. Sessions whose turn is *still running* at the drain deadline are excluded from the flush: finalizing one mid-turn (``_executor.shutdown`` below does not join it) would leave it permanently un-finalizable with its lease released — the very race the drain closes. Leaving them unfinalized keeps them recoverable. NOTE: ``server._shutdown_sessions`` (atexit) runs after ``shutdown()`` returns on the SIGTERM / stdin_closed paths and may re-finalize skipped sessions still in ``server._sessions``; the orphan path (``os._exit``) bypasses atexit. Pre-existing gap, not worsened by this ordering; a follow-up could gate ``_shutdown_sessions`` on ``not _finalized and not running``. """ self._closed.set() budget = max(0.0, wait) deadline = time.monotonic() + budget - min(_FLUSH_RESERVE_SECS, budget / 2.0) while True: remaining = deadline - time.monotonic() if remaining <= 0: break with self._turn_futures_lock: pending = [f for f in self._turn_futures if not f.done()] if not pending: break # Bounded by ``remaining``: a flat 0.05s sleep would overshoot the # deadline and eat the reserve it protects (all of it for small ``wait``). time.sleep(min(0.05, remaining)) with self._turn_futures_lock: live_sids = {sid for future, sid in self._turn_futures.items() if sid and not future.done()} self.flush_all_sessions(reason=reason, skip_sids=live_sids) self._executor.shutdown(wait=False, cancel_futures=True) def flush_all_sessions( self, *, reason: str = "shutdown", skip_sids: Collection[str] | None = None, ) -> None: """Finalize every server session except ``skip_sids`` (sessions whose turn is still live and must not spend their one-shot finalize).""" try: from tui_gateway import server except Exception: return skip = set(skip_sids or ()) for sid, session in list(getattr(server, "_sessions", {}).items()): if sid in skip: continue with contextlib.suppress(Exception): server._finalize_session(session, end_reason=f"compute_host_{reason}") def handle_frame(self, frame: dict[str, Any]) -> None: kind = str(frame.get("type") or "") handler = self._FRAME_HANDLERS.get(kind) if handler is None: self.emit({"type": "error", "request_id": frame.get("request_id"), "message": f"unknown frame type: {kind}"}) return getattr(self, handler)(frame) def _handle_shutdown(self, frame: dict[str, Any]) -> None: self.emit({"type": "shutdown.ack", "request_id": frame.get("request_id")}) # Explicit supervisor/test shutdown is a clean child-process close; # SIGTERM and orphan paths are the durability flush paths. self._closed.set() self._executor.shutdown(wait=False, cancel_futures=True) # ── Phase-0 deterministic spike frames ───────────────────────────── def _handle_seed(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") if not sid: self.emit({"type": "error", "request_id": frame.get("request_id"), "message": "sid required"}) return history = frame.get("history") if not isinstance(history, list): history = [] self._sessions[sid] = HostSession(sid=sid, agent=SpikeAgent(sid, list(history))) self.emit({"type": "session.seeded", "sid": sid, "request_id": frame.get("request_id")}) def _track_turn_future(self, future: concurrent.futures.Future, sid: str) -> None: """Register an in-flight turn against the session running it; the done callback must pop under the lock or the mapping grows for the host's life.""" with self._turn_futures_lock: self._turn_futures[future] = sid future.add_done_callback(self._untrack_turn_future) def _untrack_turn_future(self, future: concurrent.futures.Future) -> None: with self._turn_futures_lock: self._turn_futures.pop(future, None) def _handle_turn_start(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") if sid in self._sessions: self._handle_spike_turn_start(frame) return future = self._executor.submit(self._run_real_turn, dict(frame)) self._track_turn_future(future, sid) def _handle_spike_turn_start(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") session = self._sessions.get(sid) if session is None: self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "unknown session"}) return with session.lock: if session.running: self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "session busy"}) return session.running = True future = self._executor.submit(self._run_spike_turn, session, dict(frame)) self._track_turn_future(future, sid) def _handle_interrupt(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") request_id = frame.get("request_id") spike = self._sessions.get(sid) if spike is not None: request_hard_interrupt(spike.agent) self.emit({"type": "interrupt.ack", "sid": sid, "request_id": request_id, "applied": True, "applied_ns": now_ns()}) return try: from tui_gateway import server session = server._sessions.get(sid) if session is None: self.emit({"type": "interrupt.ack", "sid": sid, "request_id": request_id, "applied": False}) return # In the child, `_session_uses_compute_host()` is false, so the shared # helper interrupts the local agent and releases this process's pending # clarify Event; the parent only has a metadata mirror and cannot. server._interrupt_session_turn(sid, session) self.emit({"type": "interrupt.ack", "sid": sid, "request_id": request_id, "applied": True, "applied_ns": now_ns()}) except Exception as exc: self.emit({"type": "interrupt.ack", "sid": sid, "request_id": request_id, "applied": False, "message": str(exc)}) def _handle_respond(self, frame: dict[str, Any]) -> None: """Resolve an interactive request in the host-owned pending registry.""" sid = str(frame.get("sid") or "") request_id = frame.get("request_id") try: from tui_gateway import server if sid not in server._sessions: self.emit({"type": "respond.error", "sid": sid, "request_id": request_id, "message": "session not found"}) return params = frame.get("params") if not isinstance(params, dict): self.emit({"type": "respond.error", "sid": sid, "request_id": request_id, "message": "response params must be an object"}) return response = server._methods["clarify.respond"](request_id, params) self.emit({"type": "respond.ack", "sid": sid, "request_id": request_id, "response": response}) except Exception as exc: self.emit({"type": "respond.error", "sid": sid, "request_id": request_id, "message": str(exc)}) def _run_spike_turn(self, session: HostSession, frame: dict[str, Any]) -> None: request_id = frame.get("request_id") or uuid.uuid4().hex prompt = str(frame.get("prompt") or frame.get("text") or "") delta_count = _coerce(frame.get("delta_count", 24), int, 24) delay_s = _coerce(frame.get("delay_s", 0.001), float, 0.001) with session.lock: history = list(session.agent.history) session.agent.clear_interrupt() self.emit({"type": "turn.started", "sid": session.sid, "request_id": request_id, "started_ns": now_ns()}) def stream(delta: str) -> None: self._bump_progress() self.emit({"type": "delta", "sid": session.sid, "request_id": request_id, "text": delta, "emitted_ns": now_ns()}) try: result = session.agent.run_conversation( prompt, conversation_history=history, stream_callback=stream, delta_count=delta_count, delay_s=delay_s, ) with session.lock: session.history_version += 1 session.running = False history_version = session.history_version self._bump_progress() self.emit( {"type": "turn.end", "sid": session.sid, "request_id": request_id, "history_version": history_version, "message_count": len(result.get("messages") or []), "interrupted": bool(result.get("interrupted")), "ended_ns": now_ns()} ) except Exception as exc: # pragma: no cover - defensive host boundary with session.lock: session.running = False self.emit({"type": "turn.error", "sid": session.sid, "request_id": request_id, "message": str(exc)}) # ── Real dashboard turn path ─────────────────────────────────────── def _run_real_turn(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") request_id = str(frame.get("request_id") or uuid.uuid4().hex) if not sid: self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "sid required"}) return try: from tui_gateway import server session = self._ensure_server_session(server, frame) text = frame.get("text") if "text" in frame else frame.get("prompt", "") with session["history_lock"]: queued_gen = frame.get("queued_prompt_generation") if queued_gen is not None and int(session.get("_queued_prompt_generation", 0)) != int(queued_gen): self.emit({"type": "turn.end", "sid": sid, "request_id": request_id, "interrupted": True, "ended_ns": now_ns()}) return if session.get("running"): self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "session busy"}) return session["running"] = True session["_turn_cancel_requested"] = False session["last_active"] = time.time() server._start_inflight_turn(session, frame.get("text") if "text" in frame else frame.get("prompt")) self.emit({"type": "turn.started", "sid": sid, "request_id": request_id, "started_ns": now_ns()}) with contextlib.suppress(Exception): server._ensure_session_db_row(session) with contextlib.suppress(Exception): import hermes_undo hermes_undo.on_user_message_appended(session["session_key"]) with contextlib.suppress(Exception): server._persist_branch_seed(session) server._run_prompt_submit(request_id, sid, session, text, display_kind=frame.get("display_kind") or None) run_thread = session.get("_run_thread") if run_thread is not None and hasattr(run_thread, "join"): run_thread.join() with session["history_lock"]: history_version = int(session.get("history_version", 0)) message_count = len(session.get("history") or []) interrupted = bool(session.get("_turn_cancel_requested")) session_key = str(session.get("session_key") or "") session_info = server._session_info(session.get("agent"), session) self._bump_progress() self.emit( {"type": "turn.end", "sid": sid, "request_id": request_id, "history_version": history_version, "session_key": session_key, "message_count": message_count, "interrupted": interrupted, "ended_ns": now_ns(), "session_info": session_info, "session_info_emitted": True} ) except Exception as exc: with contextlib.suppress(Exception): from tui_gateway import server session = server._sessions.get(sid) if session is not None: with session.get("history_lock", threading.Lock()): session["running"] = False server._clear_inflight_turn(session) self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "reason": "exception", "message": str(exc)}) def _ensure_server_session(self, server: Any, frame: dict[str, Any]) -> dict: sid = str(frame.get("sid") or "") key = str(frame.get("session_key") or sid) session = server._sessions.get(sid) if session is not None: session["transport"] = self._transport if frame.get("cols") is not None: session["cols"] = int(frame.get("cols") or 80) if frame.get("cwd"): session["cwd"] = str(frame.get("cwd")) if frame.get("profile_home"): session["profile_home"] = str(frame.get("profile_home")) if isinstance(frame.get("attached_images"), list): session["attached_images"] = list(frame.get("attached_images") or []) return session history = frame.get("history") if isinstance(frame.get("history"), list) else [] profile_home = str(frame.get("profile_home") or "") session_db = None owns_db = False home_token = None secret_token = None try: if profile_home: from hermes_constants import set_hermes_home_override from agent.secret_scope import build_profile_secret_scope, set_secret_scope from hermes_state import get_shared_session_db home_token = set_hermes_home_override(profile_home) secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) # DEDICATED handle — ours only until _make_agent succeeds; after that # the agent (registered in server._sessions[sid] via _init_session or # the fallback dict below) owns it. A RAISING _make_agent is the one # path where nothing takes it, hence ``owns_db``. session_db = get_shared_session_db(Path(profile_home) / "state.db") owns_db = True agent = server._make_agent( sid, key, session_id=key, model_override=frame.get("model_override"), reasoning_config_override=frame.get("reasoning_config_override"), service_tier_override=frame.get("service_tier_override"), platform_override=frame.get("source"), context_cwd_is_launch_artifact=bool(frame.get("context_cwd_is_launch_artifact", False)), session_db=session_db, ) if server._transfer_db_to_agent(agent, session_db): owns_db = False finally: if owns_db and session_db is not None: with contextlib.suppress(Exception): from hermes_state import release_or_close release_or_close(session_db) if home_token is not None: with contextlib.suppress(Exception): from hermes_constants import reset_hermes_home_override from agent.secret_scope import reset_secret_scope reset_hermes_home_override(home_token) reset_secret_scope(secret_token) try: from tui_gateway.transport import bind_transport, reset_transport token = bind_transport(self._transport) try: server._init_session( sid, key, agent, list(history), cols=int(frame.get("cols") or 80), cwd=str(frame.get("cwd") or "") or None, session_db=session_db, source=frame.get("source"), ) finally: reset_transport(token) except Exception: # If _init_session's side machinery (slash worker, approval notify) is # unavailable, keep a minimal host-owned session rather than failing # the turn after the expensive agent build succeeded. server._sessions[sid] = { "agent": agent, "session_key": key, "history": list(history), "history_lock": threading.Lock(), "history_version": int(frame.get("history_version") or 0), "inflight_turn": None, "created_at": time.time(), "last_active": time.time(), "running": False, "attached_images": [], "image_counter": 0, "cwd": str(frame.get("cwd") or os.getcwd()), "cols": int(frame.get("cols") or 80), "slash_worker": None, "show_reasoning": server._load_show_reasoning(), "tool_progress_mode": server._load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, "model_override": frame.get("model_override"), "source": server._sanitize_client_source(frame.get("source")), "transport": self._transport, } session = server._sessions[sid] session["transport"] = self._transport session["profile_home"] = profile_home or session.get("profile_home") if isinstance(frame.get("attached_images"), list): session["attached_images"] = list(frame.get("attached_images") or []) if frame.get("model_override") is not None: session["model_override"] = frame.get("model_override") return session def _handle_reload_mcp(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") request_id = frame.get("request_id") try: from tui_gateway import server resp = server.handle_request({"id": request_id, "method": "reload.mcp", "params": {"session_id": sid, "confirm": True}}) self.emit({"type": "reload_mcp.ack", "sid": sid, "request_id": request_id, "response": resp}) except Exception as exc: self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": str(exc)}) def _handle_control(self, frame: dict[str, Any]) -> None: sid = str(frame.get("sid") or "") request_id = frame.get("request_id") route_name = str(frame.get("route_name") or "") def _error(message: str) -> None: self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": message}) def _ack(**extra: Any) -> None: self.emit({"type": "control.ack", "sid": sid, "request_id": request_id, "route_name": route_name, **extra}) def _call_method(name: str, params: dict[str, Any], failure: str) -> dict | None: """Run a server method; emit control.error and return None on error.""" response = server._methods[name](request_id, params) if "error" in response: _error(str(response["error"].get("message") or failure)) return None return response try: from tui_gateway import server route = MUTATOR_ROUTE_TABLE.get(route_name) if route is None: _error(f"unclassified route: {route_name}") return session = server._sessions.get(sid) if session is None: _error("session not found") return if route == "idle-gated" and session.get("running"): _error("session busy") return if route_name == "reload.mcp": self._handle_reload_mcp({**frame, "type": "reload_mcp"}) return if route_name == "session.save": response = _call_method("session.save", {"session_id": sid}, "session save failed") if response is not None: _ack(result=response.get("result") or {}) return if route_name == "session.compress": focus_topic = str(frame.get("command") or "").removeprefix("/compress").strip() params = {"session_id": sid, **({"focus_topic": focus_topic} if focus_topic else {})} response = _call_method("session.compress", params, "session compression failed") if response is None: return with session["history_lock"]: session_key = str(session.get("session_key") or "") history_version = int(session.get("history_version", 0)) message_count = len(session.get("history") or []) _ack( result=response.get("result") or {}, session_key=session_key, history_version=history_version, message_count=message_count, session_info=server._session_info(session.get("agent"), session), ) return command = str(frame.get("command") or "") output = server._mirror_slash_side_effects(sid, session, command) if command else "" with session["history_lock"]: messages = server._history_to_messages(list(session.get("history") or [])) history_version = int(session.get("history_version", 0)) message_count = len(session.get("history") or []) session_key = str(session.get("session_key") or "") _ack( output=output, session_key=session_key, history_version=history_version, message_count=message_count, messages=messages, session_info=server._session_info(session.get("agent"), session), ) except Exception as exc: if route_name in {"session.compress", "slash.compress"}: # The compress mirror defers the context-engine boundary notification # until the host commits. If anything raises between queueing and # finalize (e.g. building the ack's session_info), discard the pending # notification so it can't fire against a rejected boundary on a later # compress. finalize is exactly-once, so this is a no-op if the mirror # already emitted or discarded it. with contextlib.suppress(Exception): from tui_gateway import server as _server from agent.conversation_compression import finalize_context_engine_compression_notification _agent = (_server._sessions.get(sid) or {}).get("agent") if _agent is not None: finalize_context_engine_compression_notification(_agent, committed=False) _error(str(exc)) def _bump_progress(self) -> None: with self._progress_lock: self._progress_counter += 1 def _heartbeat_loop(self) -> None: while not self._closed.wait(self._heartbeat_secs): with self._turn_futures_lock: active_turns = sum(1 for f in self._turn_futures if not f.done()) with self._progress_lock: counter = self._progress_counter self.emit({"type": "hb", "active_turns": active_turns, "progress_counter": counter, "rss_mb": _rss_mb(os.getpid())}) def _parent_guard_loop(self) -> None: while not self._closed.wait(1.0): ppid = os.getppid() if ppid in {0, 1} or (self._parent_pid and ppid != self._parent_pid): self.emit({"type": "orphan", "old_ppid": self._parent_pid, "ppid": ppid}) self.shutdown(reason="orphan") os._exit(0) def _coerce(value: Any, cast: Callable[[Any], Any], default: Any) -> Any: try: return cast(value) except (TypeError, ValueError): return default def _rss_mb(pid: int) -> float: try: out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2).strip() return int(out.splitlines()[-1].strip()) / 1024.0 if out else 0.0 except Exception: return 0.0 def _default_workers() -> int: return _coerce(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "8", lambda v: max(2, int(v)), 8) def run_host(stdin: Any = None, stdout: Any = None) -> None: os.environ["HERMES_COMPUTE_HOST_CHILD"] = "1" stdin = stdin or sys.stdin host = ComputeHost(stdout=stdout or sys.stdout) shutting_down = threading.Event() def _signal_handler(_signum, _frame) -> None: if shutting_down.is_set(): return shutting_down.set() host.shutdown(reason="sigterm") raise SystemExit(0) with contextlib.suppress(Exception): signal.signal(signal.SIGTERM, _signal_handler) signal.signal(signal.SIGINT, _signal_handler) host.emit( {"type": "hello", "host_pid": os.getpid(), "boot_id": host._boot_id, "build_sha": _build_sha(), "cwd": os.getcwd(), "hermes_home": os.environ.get("HERMES_HOME", "")} ) def _reader() -> None: for raw in stdin: if host._closed.is_set(): break try: frame = json.loads(raw) except json.JSONDecodeError as exc: host.emit({"type": "error", "message": f"invalid json: {exc}"}) continue if not isinstance(frame, dict): host.emit({"type": "error", "message": "frame must be an object"}) continue host.handle_frame(frame) if frame.get("type") == "shutdown": os._exit(0) if host._closed.is_set(): break reader = threading.Thread(target=_reader, name="compute-host-control-reader", daemon=True) reader.start() try: while not host._closed.wait(0.2): if not reader.is_alive(): break finally: host.shutdown(reason="stdin_closed", wait=2.0) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Dashboard compute-host process") parser.parse_args(argv) run_host() return 0 if __name__ == "__main__": # pragma: no cover raise SystemExit(main())