refactor(runtime): centralize async bridges under an owned runtime (#376)

* feat(runtime): add application-scoped async runtime

* refactor(cli): use owned runtime for session stats

* refactor(onboard): use the owned async runtime

* docs(runtime): record async bridge ownership

* refactor(middleware): keep sync fallback synchronous

* refactor(mcp): load tools on an owned runtime

* refactor(cli): share owned runtime across entry points

* refactor(channels): make inbound sync bridge explicit

* refactor(stream): run Rich streaming on owned runtime

* chore(runtime): remove nest-asyncio dependency

* refactor(asyncio): require active loops in async code

* docs(runtime): document final event loop ownership

* fix(stream): cancel stalled owned streams

* fix(cli): recover cleanly from stream cancellation

* fix(runtime): drain executor work before shutdown

* fix(runtime): terminate cancelled shell process trees

* fix(models): let fallback bypass selector failures

* fix(cli): reset interrupt handling between turns

* docs: rm implementation spec

* fix(serve): cancel active turns during shutdown

* fix(runtime): protect settlement from waiter cancellation

* fix(backends): reject empty shell commands

* fix(runtime): terminate descendants after shell exit

* fix(mcp): keep standalone discovery off channel loop

* fix(cli): own and settle interactive prompt cancellation

* fix(serve): keep channel sends off runtime loop

* fix(stream): scope cancel context to iterator steps

* refactor(serve): require the owned async runtime

* fix(channels): keep interactive sends off runtime loop

* fix(selector): surface fallback without log spam

* test(runtime): normalize Windows shell marker

* fix(cli): serialize interactive session turns

* fix(shell): bound output drain after termination

* fix(ui): do not retry owned runtime failures

* fix(shell): allow signal-safe registry reentry

* fix(shell): avoid terminating reused process ids

* fix(channels): preserve streaming send order

* fix(cli): report runtime shutdown timeouts cleanly

* fix(mcp): guide async callers to async loader

* docs(runtime): clarify reserved async bridge APIs

* fix(runtime): bound code interpreter cleanup

* test(shell): use active Python for drain regression

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
dinos
2026-07-27 15:17:57 +02:00
committed by GitHub
parent ac58caab7b
commit 8b1451cdda
47 changed files with 4076 additions and 780 deletions
+156
View File
@@ -1,12 +1,20 @@
"""Tests for UI backend runtime selection."""
import asyncio
import threading
import time
from dataclasses import dataclass
import pytest
from EvoScientist.cli.tui_runtime import (
StreamCancellationTimeout,
normalize_ui_backend,
resolve_ui_backend,
run_streaming,
run_streaming_async,
)
from EvoScientist.runtime import AsyncRuntimeError
from tests.fakes import FakeGraphGateway
@@ -77,3 +85,151 @@ def test_run_streaming_falls_back_to_cli_on_runtime_error(monkeypatch):
gateway=FakeGraphGateway(),
)
assert result == "fallback-ok"
def test_run_streaming_does_not_retry_on_owned_runtime_error(monkeypatch):
attempts = 0
class _RuntimeFailureBackend:
def run_streaming(self, **kwargs):
nonlocal attempts
attempts += 1
raise AsyncRuntimeError("owned runtime failed")
monkeypatch.setattr(
"EvoScientist.cli.tui_runtime.get_backend",
lambda *a, **k: _RuntimeFailureBackend(),
)
monkeypatch.setattr(
"EvoScientist.cli.tui_runtime.RichStreamingBackend",
lambda: _RuntimeFailureBackend(),
)
with pytest.raises(AsyncRuntimeError, match="owned runtime failed"):
run_streaming(
ui_backend="tui",
agent=object(),
message="hello",
thread_id="t1",
show_thinking=False,
interactive=True,
gateway=FakeGraphGateway(),
)
assert attempts == 1
async def test_async_streaming_cancellation_stops_and_joins_worker(monkeypatch):
from EvoScientist.stream.display import (
discard_stream_cancel,
is_stream_cancel_requested,
)
scope = "test:async-renderer-cancel"
started = threading.Event()
finished = threading.Event()
def fake_run_streaming(**kwargs):
assert kwargs["cancel_scope"] == scope
started.set()
while not is_stream_cancel_requested(scope):
time.sleep(0.001)
finished.set()
return "stopped"
monkeypatch.setattr(
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
)
task = asyncio.create_task(run_streaming_async(cancel_scope=scope))
assert await asyncio.to_thread(started.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert finished.is_set()
discard_stream_cancel(scope)
async def test_async_streaming_can_recover_foreground_task_after_cancel(monkeypatch):
from EvoScientist.stream.display import (
discard_stream_cancel,
is_stream_cancel_requested,
)
scope = "test:async-renderer-recover"
started = threading.Event()
def fake_run_streaming(**kwargs):
started.set()
while not is_stream_cancel_requested(scope):
time.sleep(0.001)
return "[Stopped.]"
cleanup_called = False
async def fake_cleanup() -> None:
nonlocal cleanup_called
cleanup_called = True
monkeypatch.setattr(
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
)
monkeypatch.setattr(
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
fake_cleanup,
)
task = asyncio.create_task(
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
)
assert await asyncio.to_thread(started.wait, 1)
task.cancel()
assert await task == "[Stopped.]"
assert cleanup_called
assert not task.cancelled()
discard_stream_cancel(scope)
async def test_noncooperative_worker_reports_settlement_timeout(monkeypatch):
"""Cancellation timeout is an ordinary lifecycle error, not BaseException."""
from EvoScientist.cli import tui_runtime
from EvoScientist.stream.display import discard_stream_cancel
scope = "test:async-renderer-timeout"
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def fake_run_streaming(**_kwargs):
started.set()
release.wait()
finished.set()
return "late"
async def fake_cleanup() -> None:
return None
monkeypatch.setattr(tui_runtime, "run_streaming", fake_run_streaming)
monkeypatch.setattr(tui_runtime, "STREAM_CANCEL_SETTLE_TIMEOUT", 0.05)
monkeypatch.setattr(
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
fake_cleanup,
)
task = asyncio.create_task(
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
)
assert await asyncio.to_thread(started.wait, 1)
task.cancel()
with pytest.raises(StreamCancellationTimeout, match="did not stop"):
await task
assert not finished.is_set()
release.set()
assert await asyncio.to_thread(finished.wait, 1)
await asyncio.sleep(0)
discard_stream_cancel(scope)