8b1451cdda
* 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>
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""The first bounded CLI adoption of the owned async runtime."""
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
import EvoScientist.cli.commands # noqa: F401 - registers commands on app
|
|
from EvoScientist.cli import commands
|
|
from EvoScientist.cli._app import app
|
|
|
|
|
|
@pytest.mark.parametrize("args", [["sessions"], ["sessions", "stats"]])
|
|
def test_sessions_stats_uses_and_closes_cli_owned_runtime(monkeypatch, args):
|
|
execution: dict[str, object] = {}
|
|
|
|
async def fake_db_stats():
|
|
execution["thread"] = threading.current_thread().name
|
|
execution["loop"] = asyncio.get_running_loop()
|
|
return {
|
|
"db_path": "/tmp/sessions.db",
|
|
"size_bytes": 0,
|
|
"thread_count": 0,
|
|
"checkpoint_count": 0,
|
|
"write_count": 0,
|
|
"top_threads": [],
|
|
}
|
|
|
|
monkeypatch.setattr("EvoScientist.sessions.db_stats", fake_db_stats)
|
|
|
|
result = CliRunner().invoke(app, args)
|
|
|
|
assert result.exit_code == 0, result.exception
|
|
assert execution["thread"] == "evosci-async-runtime"
|
|
assert isinstance(execution["loop"], asyncio.AbstractEventLoop)
|
|
assert not any(
|
|
thread.name == "evosci-async-runtime" and thread.is_alive()
|
|
for thread in threading.enumerate()
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("args", "patch_target"),
|
|
[
|
|
(["onboard"], "EvoScientist.config.onboard.run_onboard"),
|
|
(["configure", "channels"], "EvoScientist.cli.commands._run_onboard_cli"),
|
|
],
|
|
)
|
|
def test_onboarding_commands_share_and_close_cli_runtime(
|
|
monkeypatch, args, patch_target
|
|
):
|
|
execution: dict[str, object] = {}
|
|
|
|
async def record_execution():
|
|
execution["thread"] = threading.current_thread().name
|
|
execution["loop"] = asyncio.get_running_loop()
|
|
|
|
def fake_onboard(**kwargs):
|
|
runtime = kwargs["runtime"]
|
|
execution["runtime"] = runtime
|
|
runtime.run_sync(record_execution)
|
|
return True
|
|
|
|
monkeypatch.setattr(patch_target, fake_onboard)
|
|
|
|
result = CliRunner().invoke(app, args)
|
|
|
|
assert result.exit_code == 0, result.exception
|
|
assert execution["thread"] == "evosci-async-runtime"
|
|
assert isinstance(execution["loop"], asyncio.AbstractEventLoop)
|
|
assert not any(
|
|
thread.name == "evosci-async-runtime" and thread.is_alive()
|
|
for thread in threading.enumerate()
|
|
)
|
|
|
|
|
|
def test_channel_setup_shares_and_closes_cli_runtime(monkeypatch):
|
|
execution: dict[str, object] = {}
|
|
|
|
async def record_execution():
|
|
execution["thread"] = threading.current_thread().name
|
|
execution["loop"] = asyncio.get_running_loop()
|
|
|
|
def fake_step_channels(_config, *, runtime):
|
|
runtime.run_sync(record_execution)
|
|
return {}
|
|
|
|
monkeypatch.setattr("EvoScientist.config.load_config", object)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.config.onboard.channels._step_channels", fake_step_channels
|
|
)
|
|
|
|
result = CliRunner().invoke(app, ["channel", "setup"])
|
|
|
|
assert result.exit_code == 0, result.exception
|
|
assert execution["thread"] == "evosci-async-runtime"
|
|
assert isinstance(execution["loop"], asyncio.AbstractEventLoop)
|
|
assert not any(
|
|
thread.name == "evosci-async-runtime" and thread.is_alive()
|
|
for thread in threading.enumerate()
|
|
)
|
|
|
|
|
|
def test_cli_reports_runtime_close_timeout_without_raw_exception(monkeypatch):
|
|
class _TimeoutRuntime:
|
|
def run_sync(self, factory):
|
|
return asyncio.run(factory())
|
|
|
|
def close(self):
|
|
raise TimeoutError("executor work still active")
|
|
|
|
async def fake_db_stats():
|
|
return {
|
|
"db_path": "/tmp/sessions.db",
|
|
"size_bytes": 0,
|
|
"thread_count": 0,
|
|
"checkpoint_count": 0,
|
|
"write_count": 0,
|
|
"top_threads": [],
|
|
}
|
|
|
|
monkeypatch.setattr(commands, "AsyncRuntime", _TimeoutRuntime)
|
|
monkeypatch.setattr("EvoScientist.sessions.db_stats", fake_db_stats)
|
|
|
|
result = CliRunner().invoke(app, ["sessions", "stats"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Async runtime shutdown did not complete" in result.output
|
|
assert "executor work still active" in result.output
|
|
assert not isinstance(result.exception, TimeoutError)
|