"""Foreground gateway runtime and lifecycle helpers.""" import asyncio import signal from collections.abc import Awaitable, Callable, Coroutine, Iterable from contextlib import suppress from pathlib import Path from typing import Any, cast import typer from loguru import logger from rich.console import Console from nanobot import __logo__, __version__ from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.loop import AgentLoop from nanobot.cli import terminal as cli_terminal from nanobot.cli.runtime_config import _migrate_cron_store from nanobot.cli.webui_support import ( _gateway_health_bind_note, _gateway_health_url, _host_for_local_browser, _prepare_webui_bundle_for_gateway, _print_foreground_port_conflict, _tcp_endpoint_reachable, _webui_browser_url, _webui_channel_enabled, _webui_display_url, _webui_endpoint_reachable, ) from nanobot.config.paths import is_default_workspace from nanobot.config.schema import Config from nanobot.security.network import is_loopback_host from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt from nanobot.utils.helpers import sync_workspace_templates from nanobot.webui.build import BuildMode from nanobot.webui.dev import WebUIDevError, WebUIDevServer from nanobot.webui.sidebar_state import read_webui_sidebar_state __all__ = ["_run_gateway"] console = Console() def _http_endpoint_responding(url: str, *, timeout_s: float = 0.25) -> bool: """Return whether an HTTP endpoint responds, including with an auth error.""" import urllib.error import urllib.request try: with urllib.request.urlopen(url, timeout=timeout_s): return True except urllib.error.HTTPError: return True except (OSError, urllib.error.URLError, TimeoutError, ValueError): return False async def _watch_webui_dev_server( server: WebUIDevServer, shutdown_event: asyncio.Event, *, poll_interval_s: float = 0.2, ) -> None: """Fail the foreground gateway when its owned Vite sidecar exits.""" while not shutdown_event.is_set(): await asyncio.sleep(poll_interval_s) if shutdown_event.is_set(): return server.ensure_running() def _signal_name(signum: int) -> str: with suppress(ValueError): return signal.Signals(signum).name return f"signal {signum}" def _install_gateway_shutdown_handlers( loop: asyncio.AbstractEventLoop, shutdown_event: asyncio.Event, tasks: list[asyncio.Task[Any]], print_status: Callable[[str], None], ) -> Callable[[], None]: """Install foreground gateway signal handlers and return a restore callback.""" loop_signals: list[int] = [] previous_handlers: list[tuple[int, Any]] = [] shutdown_requested = False def request_shutdown(signum: int) -> None: nonlocal shutdown_requested sig_name = _signal_name(signum) if shutdown_requested: logger.warning("Forcing gateway shutdown after repeated {}", sig_name) for task in tasks: if not task.done(): task.cancel() return shutdown_requested = True logger.info("Gateway shutdown requested by {}", sig_name) print_status("\nShutting down... Press Ctrl+C again to force.") shutdown_event.set() for signum in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(signum, request_shutdown, signum) except (NotImplementedError, RuntimeError, ValueError): try: previous = signal.getsignal(signum) signal.signal(signum, lambda sig, _frame: request_shutdown(sig)) except (RuntimeError, ValueError): logger.debug("Could not install gateway handler for {}", _signal_name(signum)) continue previous_handlers.append((signum, previous)) else: loop_signals.append(signum) def restore() -> None: for signum in loop_signals: with suppress(NotImplementedError, RuntimeError, ValueError): loop.remove_signal_handler(signum) for signum, handler in previous_handlers: with suppress(RuntimeError, ValueError): signal.signal(signum, handler) return restore def _advance_dream_cursor_if_behind(memory: Any) -> None: latest = memory.get_latest_cursor() if memory.get_last_dream_cursor() < latest: memory.set_last_dream_cursor(latest) def _commit_dream_changes(memory: Any) -> str | None: """Commit durable Dream edits, without entering the commit path for a no-op run.""" if not memory.git.is_initialized(): return None diff_body = memory.dream_content_diff() if not diff_body: return None message = memory.build_dream_commit_message( "dream: periodic memory consolidation", diff_body, ) return memory.git.auto_commit(message) _HEARTBEAT_PREAMBLE = ( "[Your response will be delivered directly to the user's messaging app. " "Output ONLY the final user-facing message. Never reference internal " "files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your " "decision process. If nothing needs reporting, respond with just " "'All clear.' and nothing else.]\n\n" ) def _heartbeat_has_active_tasks(content: str) -> bool: """True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments.""" in_comment = False in_active_section: bool = False for line in content.splitlines(): stripped = line.strip() if in_comment: if "-->" in stripped: in_comment = False continue if not stripped or stripped.startswith("#"): if stripped.startswith("##") and not stripped.startswith("###"): heading = stripped.lstrip("#").strip().lower() in_active_section = heading.startswith("active tasks") continue if stripped.startswith("" not in stripped[4:]: in_comment = True continue if in_active_section is False: continue return True return False def _pick_heartbeat_target_from_sessions( *, enabled_channels: Iterable[str], sessions: Iterable[dict[str, Any]], archived_keys: Iterable[str], unified_session_metadata: dict[str, Any] | None = None, ) -> tuple[str, str]: enabled = set(enabled_channels) archived = set(archived_keys) for item in sessions: key = item.get("key") or "" if key in archived: continue if key == UNIFIED_SESSION_KEY: route = last_channel_from_metadata(unified_session_metadata) if route is not None: channel, chat_id = route if channel not in {"cli", "system"} and channel in enabled: return channel, chat_id continue if ":" not in key: continue channel, chat_id = key.split(":", 1) if channel in {"cli", "system"}: continue if channel in enabled and chat_id: return channel, chat_id return "cli", "direct" _GATEWAY_HEALTH_MAX_CONNECTIONS = 64 _GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0 def _print_gateway_health_endpoint(host: str, port: int) -> None: """Print a usable health URL and make non-loopback binds explicit.""" console.print( f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}" f"{_gateway_health_bind_note(host)}" ) if is_loopback_host(host): return console.print( "[yellow]Warning: the unauthenticated health endpoint is listening beyond loopback " "and may be reachable from other devices. " f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]" ) async def _close_gateway_runtime( agent: AgentLoop, channels: Any, tasks: list[asyncio.Task[Any]], runtime_tasks: asyncio.Future[list[Any]] | None, *, task_wait_timeout: float = 15.0, close_timeout: float = 15.0, ) -> None: """Cancel runtime tasks, then deterministically close agent resources. Order matters: runtime tasks (including the agent loop and any in-flight turn) are cancelled and awaited -- bounded -- before exec sessions, subagents, and MCP servers are torn down, so no active turn is using a shared resource when it closes. The final close is bounded and idempotent: the agent loop's own finally also calls ``close_mcp()``, so this runs again as a no-op when that path already completed, and as the guaranteed final close when it was skipped or cut short (which previously left asyncio subprocess transports alive past ``loop.close()``, producing "RuntimeError: Event loop is closed" noise and potentially orphaned processes at interpreter exit). """ # Some SDKs swallow task cancellation while attempting to reconnect. # Close channel transports before waiting for their runners to exit. await channels.stop_all() for task in tasks: if not task.done(): task.cancel() pending: set[asyncio.Task[Any]] = set() if tasks: # Bounded: a coroutine that swallows cancellation (e.g. an SDK reconnect # loop) must not hold the stop open until systemd's timeout kills the # cgroup. Anything still pending is abandoned and closed underneath. _done, pending = await asyncio.wait(tasks, timeout=task_wait_timeout) # A task can swallow the first cancellation while unwinding. Re-cancel # timed-out tasks so an agent loop stuck draining background work reaches # its resource-cleanup phase before the explicit final close below. for task in pending: task.cancel() if runtime_tasks is not None and not runtime_tasks.done(): runtime_tasks.cancel() try: await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout) except BaseException as exc: # noqa: BLE001 - shutdown must proceed logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc) # Retrieving an already-finished gather prevents noisy unhandled exceptions, # but never wait for it here: its children were bounded individually above. if runtime_tasks is not None and runtime_tasks.done(): with suppress(asyncio.CancelledError, Exception): await runtime_tasks def _run_gateway( config: Config, *, port: int | None = None, open_browser_url: str | None = None, open_browser_ready_url: str | None = None, webui_static_dist: bool = True, webui_bundle_mode: BuildMode = "warn", webui_runtime_surface: str = "browser", webui_runtime_capabilities: dict[str, Any] | None = None, health_server_enabled: bool = True, unconfigured_provider_error: str | None = None, webui_dev_server: WebUIDevServer | None = None, ) -> None: """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" from nanobot.agent.model_presets import load_model_preset_catalog from nanobot.agent.tools.message import MessageTool from nanobot.agent.turn_delivery import TurnDeliveryFactory from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager from nanobot.config.watcher import watch_config_file from nanobot.cron.bound_runner import run_bound_cron_job from nanobot.cron.service import CronJobSkippedError, CronService from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob from nanobot.providers.factory import ( ProviderSnapshot, build_provider_snapshot, build_unconfigured_provider_snapshot, load_provider_snapshot, ) from nanobot.providers.fallback_provider import FallbackProvider from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.session.manager import SessionManager from nanobot.session.webui_turns import ( WebuiTurnCoordinator, WebuiTurnRoutePolicy, build_webui_fallback_model_observer, ) from nanobot.triggers.local_runner import run_local_trigger_queue from nanobot.triggers.local_store import LocalTriggerStore from nanobot.webui.token_usage import TokenUsageHook port = port if port is not None else config.gateway.port webui_url = _webui_browser_url(config) gateway_host_for_browser = _host_for_local_browser(config.gateway.host) if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port): _print_foreground_port_conflict( webui_url=webui_url, gateway_host=config.gateway.host, gateway_port=port, ) raise typer.Exit(1) if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url): _print_foreground_port_conflict( webui_url=webui_url, gateway_host=config.gateway.host, gateway_port=port, ) raise typer.Exit(1) console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...") _prepare_webui_bundle_for_gateway( config, mode=webui_bundle_mode, webui_static_dist=webui_static_dist, ) sync_workspace_templates(config.workspace_path) bus = MessageBus() runtime_events = RuntimeEventBus() fallback_model_observer = build_webui_fallback_model_observer(bus) def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot: if isinstance(snapshot.provider, FallbackProvider): snapshot.provider.set_fallback_model_observer(fallback_model_observer) return snapshot def _load_gateway_provider_snapshot( *args: Any, **kwargs: Any, ) -> ProviderSnapshot: try: return _observe_fallback_models(load_provider_snapshot(*args, **kwargs)) except ValueError as exc: if unconfigured_provider_error is None: raise return build_unconfigured_provider_snapshot(config, str(exc)) if unconfigured_provider_error is not None: provider_snapshot = build_unconfigured_provider_snapshot( config, unconfigured_provider_error, ) else: try: provider_snapshot = _observe_fallback_models(build_provider_snapshot(config)) except ValueError as exc: console.print(f"[red]Error: {exc}[/red]") raise typer.Exit(1) from exc session_manager = SessionManager(config.workspace_path) # Self-heal the gateway state file with the current PID after any restart. from nanobot.config.loader import get_config_path from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths config_path = str(get_config_path().resolve(strict=False)) GatewayRuntime.refresh_state_pid( paths=GatewayRuntimePaths.for_instance( workspace=str(config.workspace_path) if not is_default_workspace(config.workspace_path) else None, config_path=config_path, ) ) # Preserve existing single-workspace installs, but keep custom workspaces clean. if is_default_workspace(config.workspace_path): _migrate_cron_store(config) # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) trigger_store = LocalTriggerStore(config.workspace_path) turn_delivery_factory = TurnDeliveryFactory( bus, runtime_events, route_policy=WebuiTurnRoutePolicy(session_manager), ) # Create agent with cron service agent = AgentLoop.from_config( config, bus, provider=provider_snapshot.provider, model=provider_snapshot.model, context_window_tokens=provider_snapshot.context_window_tokens, cron_service=cron, session_manager=session_manager, image_generation_provider_configs=image_gen_provider_configs(config), provider_snapshot_loader=_load_gateway_provider_snapshot, preset_catalog_loader=load_model_preset_catalog, runtime_events=runtime_events, turn_delivery_factory=turn_delivery_factory, provider_signature=provider_snapshot.signature, hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)], local_trigger_store=trigger_store, hook_factories=[create_file_edit_activity_hook], ) def _schedule_webui_background(awaitable: Awaitable[None]) -> None: agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable)) webui_turn_coordinator = WebuiTurnCoordinator( bus=bus, sessions=session_manager, schedule_background=_schedule_webui_background, ) webui_turn_coordinator.subscribe(runtime_events) from nanobot.bus.events import OutboundMessage from nanobot.session.keys import session_key_for_channel def _channel_session_key(channel: str, chat_id: str) -> str: return session_key_for_channel( channel, chat_id, unified_session=config.agents.defaults.unified_session, ) async def _deliver_to_channel( msg: OutboundMessage, *, record: bool = False, session_key: str | None = None, ) -> None: """Publish a user-visible message and mirror it into that channel's session.""" metadata = dict(msg.metadata or {}) record = record or bool(metadata.pop("_record_channel_delivery", False)) if metadata != (msg.metadata or {}): msg = OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=msg.content, reply_to=msg.reply_to, media=msg.media, metadata=metadata, buttons=msg.buttons, ) if ( record and msg.channel != "cli" and msg.content.strip() and hasattr(session_manager, "get_or_create") and hasattr(session_manager, "save") ): key = session_key or _channel_session_key(msg.channel, msg.chat_id) session = session_manager.get_or_create(key) extra: dict[str, Any] = {"_channel_delivery": True} if msg.media: extra["media"] = list(msg.media) session.add_message("assistant", msg.content, **extra) session_manager.save(session) await bus.publish_outbound(msg) message_tool = agent.tools.get("message") if isinstance(message_tool, MessageTool): message_tool.set_send_callback(_deliver_to_channel) # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" async def _silent(*_args: Any, **_kwargs: Any) -> None: pass # Dream is an internal job — run directly, not through the agent loop. if job.name == "dream": from nanobot.agent.memory import DreamRunProgress, MemoryStore dream_session_key = MemoryStore.dream_session_key prune_dream_sessions = MemoryStore.prune_dream_sessions store = agent.context.memory progress = DreamRunProgress() resp = None diff_body = "" try: result = store.build_dream_prompt() if result is None: logger.info("Dream: nothing to process") return None prompt, last_cursor = result key = dream_session_key() dream_runtime = agent.dream_runtime() resp = await agent.process_direct( prompt, session_key=key, ephemeral=True, tools=store.build_dream_tools(), on_progress=progress, runtime=dream_runtime, ) # The real file delta grounds the audit record; clean completion # decides whether this history batch has finished processing. diff_body = store.dream_content_diff() completed = MemoryStore.dream_run_completed( resp, had_tool_errors=progress.had_tool_errors, ) if completed: store.set_last_dream_cursor(last_cursor) if diff_body: logger.info( "Dream cron job completed, cursor advanced to {}", last_cursor, ) else: logger.info( "Dream cron job completed with no memory changes; " "cursor advanced to {}", last_cursor, ) else: logger.warning( "Dream cron job did not complete; cursor remains at {}", store.get_last_dream_cursor(), ) except Exception: logger.exception("Dream cron job failed") finally: from nanobot.webui.token_usage import record_response_token_usage record_response_token_usage( resp, source="dream", timezone_name=config.agents.defaults.timezone, ) sha = _commit_dream_changes(store) if sha: logger.info("Dream commit: {}", sha) store.compact_history() prune_dream_sessions(agent.sessions.sessions_dir) return None # Heartbeat is a system job that checks HEARTBEAT.md for active tasks. if job.name == "heartbeat": heartbeat_file = config.workspace_path / "HEARTBEAT.md" try: content = heartbeat_file.read_text(encoding="utf-8") except OSError: logger.debug("Heartbeat: HEARTBEAT.md missing") return None if not _heartbeat_has_active_tasks(content): logger.debug("Heartbeat: HEARTBEAT.md has no active tasks") return None channel, chat_id = _pick_heartbeat_target() if channel == "cli": return None prompt = ( _HEARTBEAT_PREAMBLE + f"You are executing periodic heartbeat tasks. Read the active tasks below, perform each one, and report what you did:\n\n{content}" ) # Internal check: funnel all output through the post-run gate so the # turn can't deliver directly via the message tool and skip it. suppress_token = None if isinstance(message_tool, MessageTool): suppress_token = message_tool.set_suppress_delivery(True) try: resp = await agent.process_direct( prompt, session_key="heartbeat", channel=channel, chat_id=chat_id, on_progress=_silent, ) finally: if isinstance(message_tool, MessageTool) and suppress_token is not None: message_tool.reset_suppress_delivery(suppress_token) # Keep a small tail of heartbeat history so the loop stays bounded. session = agent.sessions.get_or_create("heartbeat") session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages) agent.sessions.save(session) if not resp or not resp.content: return response = resp.content evaluator_prompt = resolve_evaluator_prompt(config.workspace_path) # Fail closed: stay silent on evaluator failure instead of notifying. should_notify = await evaluate_response( response=response, task_context=prompt, provider=agent.provider, model=agent.model, evaluator_prompt=evaluator_prompt, default_notify=False, ) if should_notify: logger.info("Heartbeat: completed, delivering response") await _deliver_to_channel( OutboundMessage(channel=channel, chat_id=chat_id, content=response), record=True, ) else: logger.info("Heartbeat: silenced by post-run evaluation") return response if is_bound_cron_job(job): return await run_bound_cron_job(job, agent=agent, cron=cron) reason = "unbound agent cron job must be recreated from a chat session" logger.warning( "Cron: skipped unbound agent job '{}' ({}): {}", job.name, job.id, reason, ) raise CronJobSkippedError(reason) cron.on_job = on_cron_job def _webui_runtime_model_name() -> str | None: return agent.model.strip() or None def _webui_skill_state_action(disabled_skills: set[str]) -> None: config.agents.defaults.disabled_skills = sorted(disabled_skills) agent.context.skills.disabled_skills = set(disabled_skills) agent.subagents.disabled_skills = set(disabled_skills) # Create channel manager (forwards SessionManager so the WebSocket channel # can serve the embedded webui's REST surface). channels = ChannelManager( config, bus, session_manager=session_manager, cron_service=cron, local_trigger_store=trigger_store, webui_runtime_model_name=_webui_runtime_model_name, webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session, webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session, webui_static_dist=webui_static_dist, webui_runtime_surface=webui_runtime_surface, webui_runtime_capabilities=webui_runtime_capabilities, webui_skill_state_action=_webui_skill_state_action, config_path=Path(config_path), ) def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" sidebar_state = read_webui_sidebar_state() unified_metadata = None if config.agents.defaults.unified_session: record = session_manager.read_session_metadata(UNIFIED_SESSION_KEY) if isinstance(record, dict) and isinstance(record.get("metadata"), dict): unified_metadata = record["metadata"] return _pick_heartbeat_target_from_sessions( enabled_channels=channels.enabled_channels, sessions=session_manager.list_sessions(), archived_keys=sidebar_state.get("archived_keys", []), unified_session_metadata=unified_metadata, ) if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") cron_status = cron.status() cron_job_count = cast(int, cron_status["jobs"]) if cron_job_count > 0: console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs") hb_cfg = config.gateway.heartbeat if hb_cfg.enabled: console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") else: console.print("[yellow]✗[/yellow] Heartbeat: disabled") async def _health_server(host: str, health_port: int) -> None: """Lightweight HTTP health endpoint on the gateway port.""" import json as _json connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS) async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: if connection_slots.locked(): writer.close() return async with connection_slots: try: data = await asyncio.wait_for( reader.read(4096), timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS, ) request_line = data.split(b"\r\n", 1)[0].decode( "utf-8", errors="replace", ) method, path = "", "" parts = request_line.split(" ") if len(parts) >= 2: method, path = parts[0], parts[1] if method == "GET" and path == "/health": body = _json.dumps({"status": "ok"}) status = "200 OK" content_type = "application/json" else: body = "Not Found" status = "404 Not Found" content_type = "text/plain" resp = ( f"HTTP/1.0 {status}\r\n" f"Content-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n" "Connection: close\r\n" f"\r\n{body}" ) writer.write(resp.encode()) await writer.drain() except (asyncio.TimeoutError, ConnectionError): pass finally: writer.close() server = await asyncio.start_server(handle, host, health_port) _print_gateway_health_endpoint(host, health_port) async with server: await server.serve_forever() # Register Dream system job (idempotent on restart) from nanobot.cron.types import CronJob, CronPayload, CronSchedule dream_cfg = config.agents.defaults.dream if dream_cfg.enabled: cron.register_system_job(CronJob( id="dream", name="dream", schedule=dream_cfg.build_schedule(config.agents.defaults.timezone), payload=CronPayload(kind="system_event"), )) console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}") else: console.print("[yellow]○[/yellow] Dream: disabled") _advance_dream_cursor_if_behind(agent.context.memory) # Register Heartbeat system job (idempotent on restart) if hb_cfg.enabled: cron.register_system_job(CronJob( id="heartbeat", name="heartbeat", schedule=CronSchedule( kind="every", every_ms=hb_cfg.interval_s * 1000, tz=config.agents.defaults.timezone, ), payload=CronPayload(kind="system_event"), )) async def _open_browser_when_ready() -> None: """Wait for the gateway to bind, then point the user's browser at the webui.""" if not open_browser_url: return import webbrowser from urllib.parse import urlparse # Channels start asynchronously. When the caller supplies a backend # readiness route, wait for an actual HTTP response rather than probing # the WebSocket listener with an incomplete TCP connection. if open_browser_ready_url: for _ in range(40): # ~4s max per listener if await asyncio.to_thread( _http_endpoint_responding, open_browser_ready_url, ): break await asyncio.sleep(0.1) parsed = urlparse(open_browser_url) target_host = parsed.hostname or config.gateway.host or "127.0.0.1" target_port = parsed.port or port for _ in range(40): # ~4s max try: _reader, writer = await asyncio.open_connection( target_host, target_port, ) writer.close() with suppress(Exception): await writer.wait_closed() break except OSError: await asyncio.sleep(0.1) display_url = _webui_display_url(open_browser_url) try: webbrowser.open(open_browser_url) console.print(f"[green]✓[/green] Opened browser at {display_url}") except Exception as e: console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]") async def run() -> None: tasks: list[asyncio.Task[Any]] = [] shutdown_task: asyncio.Task[Any] | None = None runtime_tasks: asyncio.Future[list[Any]] | None = None shutdown_event = asyncio.Event() cli_terminal._ensure_interactive_tty_mode() restore_shutdown_handlers = _install_gateway_shutdown_handlers( asyncio.get_running_loop(), shutdown_event, tasks, console.print, ) try: await cron.start() # Re-read once on first admission to close the watcher subscription window. agent.runtime_resolver.invalidate() tasks = [ asyncio.create_task( watch_config_file( Path(config_path), lambda: agent.invalidate_runtime_config(), ), name="nanobot-config-watcher", ), asyncio.create_task(agent.run(), name="nanobot-agent-loop"), asyncio.create_task(channels.start_all(), name="nanobot-channels"), asyncio.create_task( run_local_trigger_queue( store=trigger_store, submit_turn=agent.submit_local_trigger_turn, is_channel_enabled=lambda name: channels.get_channel(name) is not None, ), name="nanobot-local-triggers", ), ] if health_server_enabled: tasks.append(asyncio.create_task( _health_server(config.gateway.host, port), name="nanobot-health-server", )) if open_browser_url: tasks.append(asyncio.create_task( _open_browser_when_ready(), name="nanobot-open-browser", )) if webui_dev_server is not None: tasks.append(asyncio.create_task( _watch_webui_dev_server(webui_dev_server, shutdown_event), name="nanobot-webui-dev-server", )) runtime_tasks = asyncio.gather(*tasks) shutdown_task = asyncio.create_task( shutdown_event.wait(), name="nanobot-gateway-shutdown", ) done, _pending = await asyncio.wait( {runtime_tasks, shutdown_task}, return_when=asyncio.FIRST_COMPLETED, ) if runtime_tasks in done: await runtime_tasks else: runtime_tasks.cancel() except KeyboardInterrupt: console.print("\nShutting down...") except WebUIDevError: raise except Exception: import traceback console.print("\n[red]Error: Gateway crashed unexpectedly[/red]") console.print(traceback.format_exc()) finally: try: if shutdown_task and not shutdown_task.done(): shutdown_task.cancel() with suppress(asyncio.CancelledError): await shutdown_task cron.stop() agent.stop() # Cancel runtime tasks first, then deterministically close # exec/MCP resources while the event loop is still alive. await _close_gateway_runtime(agent, channels, tasks, runtime_tasks) # Flush all cached sessions to durable storage before exit. # This prevents data loss on filesystems with write-back # caching (rclone VFS, NFS, FUSE mounts, etc.). flushed = agent.sessions.flush_all() if flushed: logger.info("Shutdown: flushed {} session(s) to disk", flushed) finally: restore_shutdown_handlers() asyncio.run(run())