diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py index b8c0f3b..f69c36b 100644 --- a/EvoScientist/channels/channel_manager.py +++ b/EvoScientist/channels/channel_manager.py @@ -757,7 +757,16 @@ class ChannelManager: """Start the HTTP health-check endpoint (if configured).""" if self._health_port and self._health_server is None: self._health_server = _HealthServer(self, self._health_port) - await self._health_server.start() + try: + await self._health_server.start() + except OSError as e: + logger.warning( + "Health server failed to bind on port %s: %s — " + "health endpoint disabled, channel will still start normally.", + self._health_port, + e, + ) + self._health_server = None async def stop_health(self) -> None: """Stop the HTTP health-check endpoint.""" diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py index 82dcb7f..e2c20de 100644 --- a/EvoScientist/cli/channel.py +++ b/EvoScientist/cli/channel.py @@ -445,8 +445,11 @@ def _start_channels_bus_mode( try: loop.run_until_complete(_run()) except Exception as e: - _channel_logger.error(f"Bus thread error: {e}") + _channel_logger.error( + "Bus thread terminated with error: %s", e, exc_info=True + ) finally: + _channel_logger.debug("Bus thread event loop closed") loop.close() thread = threading.Thread(target=_bus_thread_entry, daemon=True) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index b870bad..f8574ef 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -504,10 +504,6 @@ def serve( Starts all configured channels and processes messages via the agent. Press Ctrl+C to shut down. """ - import nest_asyncio # type: ignore[import-untyped] - - nest_asyncio.apply() - from ..config import apply_config_to_env, get_effective_config cli_overrides = {} diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 898e2ea..2ce3483 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -1068,10 +1068,6 @@ def _run_streaming( Returns: The final response text. """ - import nest_asyncio - - nest_asyncio.apply() - state = _state if _state is not None else StreamState() _thinking_sent = False _todo_sent = False @@ -1180,11 +1176,32 @@ def _run_streaming( vertical_overflow="visible", ) as live: live.update(create_streaming_display(is_waiting=True)) + # Determine how to run the async streaming coroutine. + # - In TUI mode (Textual), there's already a running event loop; + # nest_asyncio is needed to allow run_until_complete inside it. + # - In serve/CLI mode, the main thread has no running loop; + # use a fresh event loop directly (no nest_asyncio needed or wanted, + # since nest_asyncio.apply() patches globally and breaks the bus + # thread's event loop Task-context detection). try: - loop = _get_event_loop() + running_loop = asyncio.get_running_loop() except RuntimeError: - # No current event loop - loop = _create_event_loop() + running_loop = None + + if running_loop is not None: + # Already inside a running loop (TUI) — must use nest_asyncio. + # NOTE: nest_asyncio.apply() is global and irreversible within + # the process; avoid mixing TUI and serve modes in one process. + import nest_asyncio # type: ignore[import-untyped] + + nest_asyncio.apply() + loop = running_loop + else: + # No running loop (serve/CLI) — create a fresh one + try: + loop = _get_event_loop() + except RuntimeError: + loop = _create_event_loop() async def _run_with_refresh() -> None: async def _periodic_refresh() -> None: