diff --git a/src/korvid/ui/app_surfaces.py b/src/korvid/ui/app_surfaces.py index 36b1325a9..1dc2b838d 100644 --- a/src/korvid/ui/app_surfaces.py +++ b/src/korvid/ui/app_surfaces.py @@ -184,6 +184,11 @@ def echo_user(self, text: str) -> None: self._app._agent_panel.echo_user(text) def apply_event(self, event: AgentEvent) -> None: + if not self._app.is_running: + # Textual prunes every widget before it sends the App its Unmount, + # which is where the agent session is marked down. A turn that + # settles in between has no panel left to update (#476). + return self._app._agent_panel.apply_event(event) diff --git a/tests/evals/operation_app.py b/tests/evals/operation_app.py index 02f5d0d57..93b4d3abf 100644 --- a/tests/evals/operation_app.py +++ b/tests/evals/operation_app.py @@ -725,7 +725,25 @@ def _apply_intervention(self) -> None: f"{self._journey.id}: dialog_intervention replacement did not apply" ) + async def _await_dialog_composed(self, pilot: Any) -> None: + """Wait until a current approval dialog has its widgets, or closes. + + Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478): reading the + title then would find none. A dialog that closes first is left to + `handle`, which already treats it as closed before a decision. + """ + screen = self._app.screen + if not isinstance(screen, ConfirmScreen): + return + await until( + pilot, + lambda: self._app.screen is not screen or bool(screen.query(".confirm-title")), + label="approval dialog composed", + ) + async def handle(self, pilot: Any) -> None: + await self._await_dialog_composed(pilot) screen = self._app.screen if not isinstance(screen, ConfirmScreen): if self._remaining <= 0 or not self._dialog_seen: diff --git a/tests/evals/test_operation_journeys.py b/tests/evals/test_operation_journeys.py index 54e97d943..7ba9d569c 100644 --- a/tests/evals/test_operation_journeys.py +++ b/tests/evals/test_operation_journeys.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import AsyncIterator, Callable @@ -31,6 +32,7 @@ from korvid.evals.scripted import ScriptedProvider from korvid.tools.executor import RecordedExecution, ToolOutcome from korvid.tools.structured import dump_yaml +from korvid.ui.widgets.confirm_screen import ConfirmScreen from . import operation_app from .operation_app import ( @@ -1331,6 +1333,25 @@ def polls_that_miss_every_dialog( assert run.audit == () +async def test_a_dialog_still_composing_is_read_once_it_has_its_widgets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478). The driver must + wait for the dialog's title instead of reading a dialog that has none.""" + original = ConfirmScreen._compose + + async def slow_compose(self: ConfirmScreen) -> None: + await asyncio.sleep(0.3) + await original(self) + + monkeypatch.setattr(ConfirmScreen, "_compose", slow_compose) + run = await run_scripted_journey("scale-deployment-up", tmp_path) + observed = [entry for entry in run.journal if entry["event"] == "dialog_observed"] + assert [entry["result"] for entry in observed] == ["matched"] + assert run.grade.hard_failures == () + + def test_the_replacement_journey_declares_its_intervention_in_the_fixture() -> None: """B1: the mid-dialog replacement is fixture data, not test code, so `tests.evals.operation_campaign` runs the identical journey.""" diff --git a/tests/mcp/test_stdio.py b/tests/mcp/test_stdio.py index 8e8a097f7..9ed814023 100644 --- a/tests/mcp/test_stdio.py +++ b/tests/mcp/test_stdio.py @@ -7,6 +7,7 @@ import io import json import os +import re import signal import sys from collections.abc import AsyncIterator @@ -49,6 +50,13 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara "stdio.cancellable_stdin = _relay_stdin\n" "from korvid.mcp import _stdio_output\n" "_stdio_output._posix_stdout = _stdio_output._relay_stdout\n" + # The child-watcher race (#472) is rare on its own; emit its line + # every time, so every relay test that reads the server's stderr + # proves it filters it rather than passing on a lucky run. + "import logging\n" + "logging.getLogger('asyncio').warning(\n" + " 'child process pid %d exit status already read: will report returncode 255', 0\n" + ")\n" "from korvid.cli import main", ) return StdioServerParameters( @@ -58,6 +66,55 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara ) +#: asyncio's child-watcher warning when a child it watches was reaped first +#: (#472). The tests force the Windows-only relays onto POSIX, where killing a +#: relay that just exited on its own runs `Popen.send_signal`'s poll +#: (bpo-38630); that `waitpid` reaps the relay before the event loop's watcher +#: does, and the watcher logs this line. On Windows nothing reaps, so +#: production never writes it. Pidfd watcher first, then the threaded one. +_RELAY_WATCHER_RACE = re.compile( + r"child process pid \d+ exit status already read: +will report returncode 255" + r"|Unknown child process pid \d+, will report returncode 255" +) + + +def _server_stderr(err: bytes | str) -> str: + """The stdio server's stderr, without the relay child-watcher race.""" + text = err.decode() if isinstance(err, bytes) else err + return "".join( + line + for line in text.splitlines(keepends=True) + if not _RELAY_WATCHER_RACE.fullmatch(line.rstrip("\r\n")) + ) + + +@pytest.mark.parametrize( + "warning", + [ + "child process pid 3098 exit status already read: will report returncode 255", + "Unknown child process pid 3098, will report returncode 255", + ], + ids=["pidfd-watcher", "threaded-watcher"], +) +def test_server_stderr_drops_the_relay_child_watcher_race(warning: str) -> None: + """Forcing the Windows-only relays onto POSIX lets asyncio's child watcher + lose a race with `Popen.send_signal`'s poll (#472). Its warning is the + interpreter racing itself, not output the server chose to write.""" + err = f"fd-handler-noise\n{warning}\nkorvid: MCP stdio connection interrupted.\n" + assert _server_stderr(err.encode()) == ( + "fd-handler-noise\nkorvid: MCP stdio connection interrupted.\n" + ) + + +def test_server_stderr_keeps_every_other_line() -> None: + err = ( + "korvid: MCP stdio connection interrupted.\n" + "relay: Unknown child process pid 7, will report returncode 255\n" + "child process pid 7 exit status already read\n" + ) + assert _server_stderr(err.encode()) == err + + @asynccontextmanager async def _backend( state: Path, executor: RecordingExecutor | None = None @@ -572,7 +629,7 @@ async def test_sigint_exits_without_host_stdin_eof( err = await process.stderr.read() assert process.returncode == 1, err.decode() assert all(json.loads(line)["jsonrpc"] == "2.0" for line in out.splitlines()) - assert err.decode() == "korvid: MCP stdio connection interrupted.\n" + assert _server_stderr(err) == "korvid: MCP stdio connection interrupted.\n" finally: executor.release.set() @@ -621,7 +678,7 @@ async def test_shutdown_exits_without_draining_stdout( assert process.stderr is not None assert process.returncode == (1 if interrupt else 0) expected = b"korvid: MCP stdio connection interrupted.\n" if interrupt else b"" - assert await process.stderr.read() == expected + assert _server_stderr(await process.stderr.read()) == expected.decode() finally: host.close() @@ -675,7 +732,7 @@ async def test_stdio_delivers_large_utf8_output_without_truncation( assert not result.is_error assert getattr(result.content[0], "text", None) == executor.result errors.seek(0) - assert errors.read() == "" + assert _server_stderr(errors.read()) == "" @pytest.mark.skipif(sys.platform == "win32", reason="POSIX nonblocking partial writes") @@ -872,7 +929,10 @@ async def noisy_list_tools(self, ctx, params): out, err = await asyncio.wait_for(process.communicate(), 5) assert process.returncode == 0, err.decode() assert json.loads(out) == {"restored": True} - assert sorted(err.decode().splitlines()) == ["fd-handler-noise", "python-handler-noise"] + assert sorted(_server_stderr(err).splitlines()) == [ + "fd-handler-noise", + "python-handler-noise", + ] @pytest.mark.parametrize("relay", [False, True]) diff --git a/tests/ui/test_agent_interrupt.py b/tests/ui/test_agent_interrupt.py index ba47c68e9..779ecaeda 100644 --- a/tests/ui/test_agent_interrupt.py +++ b/tests/ui/test_agent_interrupt.py @@ -21,7 +21,7 @@ from .agent_write_support import Recorder, _expand_panel from .agent_write_support import make_app as make_write_app -from .waits import until +from .waits import HANG_GUARD_SECONDS, until def test_fake_session_interrupt_usage_matches_live_cumulative_totals() -> None: @@ -335,6 +335,48 @@ async def _slow(self) -> AsyncIterator[AgentEvent]: assert session.cleanup_done +async def test_a_turn_that_settles_after_the_panel_is_pruned_does_not_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Textual removes every widget before it sends the App its Unmount, which + is where the agent session is marked down. A turn interrupted just before + quitting can settle in between, after the panel's children are gone, and + must not raise from shutdown (#476).""" + pruned = asyncio.Event() + + def note_unmount(self: AgentPanel) -> None: + pruned.set() + + monkeypatch.setattr(AgentPanel, "on_unmount", note_unmount, raising=False) + + class PrunedPanelSession(BlockingSession): + def __init__(self) -> None: + super().__init__() + self.settled_after_prune = False + + def run_turn(self, user_text: str) -> AsyncIterator[AgentEvent]: + self.prompts.append(user_text) + return self._until_pruned() + + async def _until_pruned(self) -> AsyncIterator[AgentEvent]: + try: + yield TextDelta(text="let me check") + await asyncio.Event().wait() + finally: + # Settle the interrupted turn as soon as Textual has pruned the + # panel: after `_close_all` removed it, before the App's Unmount. + await asyncio.wait_for(pruned.wait(), timeout=HANG_GUARD_SECONDS) + self.settled_after_prune = True + + session = PrunedPanelSession() + app = make_app(session) + async with app.run_test() as pilot: + await _start_turn(app, pilot, "check") + await until(pilot, lambda: bool(session.prompts), label="turn running") + app.action_interrupt_agent() + assert session.settled_after_prune + + async def test_unmount_marks_the_agent_down_before_its_first_await( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/uv.lock b/uv.lock index 0695a302b..d73776f86 100644 --- a/uv.lock +++ b/uv.lock @@ -819,11 +819,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.32.0" +version = "4.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, ] [[package]] @@ -966,14 +966,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.62" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/ff/fee293f1ec3e1b64db6eb270e00964f29c086d769201474cedb8d1608930/gitpython-3.2.1.tar.gz", hash = "sha256:0f1229ec5c057bdabdeeda8bda4f17bda679ab33dbccc00f24e6a480553c8613", size = 241688, upload-time = "2026-10-14T12:20:20.589Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/50/2a923c4dc02522ff9567f07c67ed1deca6d5fa9474f4708168b8088dfa37/gitpython-3.2.1-py3-none-any.whl", hash = "sha256:d66b0832aa8755c1a84892937282df0befb16c97e5a64aeebd68be9c5e323f53", size = 231548, upload-time = "2026-10-14T12:20:18.844Z" }, ] [[package]] @@ -2125,11 +2125,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -3434,17 +3434,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.7.13" +version = "21.14.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/50/c9b84eb106d0db420b9878dac0a386c5791726f127ce20b06897f6e3a1e9/virtualenv-21.7.13.tar.gz", hash = "sha256:0355558b6f33619aab31347e43643b0ebc97f61ea3acf617b2b69e1f8a843d11", size = 5360306, upload-time = "2026-09-18T04:35:49.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/aa/bff50daee0488e16d611258a9b6c5a1d0c9e128f8ca424e9f4e1c0444ef2/virtualenv-21.14.13.tar.gz", hash = "sha256:de5307ba4b9a0001d9c8ea02832d38f63ca432880f416c1bface61c4323fd905", size = 5570301, upload-time = "2026-10-22T20:10:16.952Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/ce/e74453531b0c49a58d0e8a4f9bab4495705859fee4a4f7de27d58f4a791a/virtualenv-21.7.13-py3-none-any.whl", hash = "sha256:1bea5af7463f59c4719db48fe739579a2a4f569c96f26c086edda85c96da9f59", size = 5328680, upload-time = "2026-09-18T04:35:47.194Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/c10d8a44a29fb94a18b43fdf11f38caca53ae7c9406f11a72b1cb8f624f1/virtualenv-21.14.13-py3-none-any.whl", hash = "sha256:5fbb04138a4861580b78ba9110d529a26bc48fcc93c8ec36bc0202eeed96a788", size = 5557094, upload-time = "2026-10-22T20:10:15.096Z" }, ] [[package]]