From 497ef9e0c718b5df1cea6ac1135217cd662fbf5a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:04:30 -0700 Subject: [PATCH] fix(cli): keep monitor repaints safe during prompt handoff --- hermes_cli/cli_subagent_monitor.py | 11 +++++++- hermes_cli/cli_terminal_mixin.py | 4 +-- tests/cli/test_subagent_monitor_prompts.py | 32 ++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/hermes_cli/cli_subagent_monitor.py b/hermes_cli/cli_subagent_monitor.py index 48aad8776e1dc..064c415c3b238 100644 --- a/hermes_cli/cli_subagent_monitor.py +++ b/hermes_cli/cli_subagent_monitor.py @@ -56,6 +56,15 @@ def refresh(self, now=None): self.selected_id = entries[0]['subagent_id'] if entries else None return changed + def invalidate(self): + from hermes_cli.cli_terminal_mixin import _run_on_app_loop + + app = self.app + if app is not None: + # Teardown clears app.loop; don't let it interleave with a worker's + # invalidate call, which reads the loop more than once. + _run_on_app_loop(app, app.invalidate) + def tick(self): now = time.monotonic() if now - self._last_poll < 1: @@ -63,7 +72,7 @@ def tick(self): self._last_poll = now if self.refresh(): if self.app is not None: - self.app.invalidate() + self.invalidate() else: self.cli._invalidate() diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 5002313b7028e..dcfaa60182b54 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -104,8 +104,8 @@ def _paint_now(self) -> None: if getattr(self, "_terminal_io_broken", False): return monitor = getattr(self, "_subagent_monitor", None) - if monitor is not None and monitor.app is not None: - monitor.app.invalidate() + if monitor is not None: + monitor.invalidate() app = getattr(self, "_app", None) if app is not None: self._app_invalidate(app, "paint_now", swallow=True) diff --git a/tests/cli/test_subagent_monitor_prompts.py b/tests/cli/test_subagent_monitor_prompts.py index 15f6ba0513ff6..5462a2cf1f195 100644 --- a/tests/cli/test_subagent_monitor_prompts.py +++ b/tests/cli/test_subagent_monitor_prompts.py @@ -1,9 +1,13 @@ """Blocking prompts reclaim input from the nested monitor without a keypress.""" import asyncio +import threading from types import SimpleNamespace +import pytest -def test_prompt_paint_yields_monitor_but_ordinary_paint_does_not(): + +@pytest.mark.parametrize('paint_source', ['modal', 'tick']) +def test_prompt_paint_yields_monitor_but_ordinary_paint_does_not(monkeypatch, paint_source): from hermes_cli.cli_subagent_monitor import SubagentMonitor, build_monitor_application, install_dock from hermes_cli.cli_terminal_mixin import CLITerminalMixin from prompt_toolkit.input import create_pipe_input @@ -29,10 +33,34 @@ async def run(): task = asyncio.create_task(app.run_async()) await asyncio.wait_for(rendered.wait(), 3) try: + rendered.clear() await asyncio.to_thread(CLITerminalMixin._paint_now, cli) + await asyncio.wait_for(rendered.wait(), 3) assert not task.done(), 'ordinary paints must not dismiss the monitor' + loop = asyncio.get_running_loop() + ui_thread = threading.get_ident() + stopped = threading.Event() + task.add_done_callback(lambda _: stopped.set()) + schedule = loop.call_soon_threadsafe + + def schedule_with_teardown(callback, *args, **kwargs): + handle = schedule(callback, *args, **kwargs) + if (callback == app.on_invalidate.fire + and threading.get_ident() != ui_thread): + # Let an already-queued frame see the modal and finish + # teardown while the worker is still in invalidate(). + schedule(app._redraw) + assert stopped.wait(3), 'monitor did not finish teardown' + return handle + setattr(cli, name, {'pending': True}) - await asyncio.to_thread(CLITerminalMixin._paint_now, cli) + with monkeypatch.context() as patch: + patch.setattr(loop, 'call_soon_threadsafe', schedule_with_teardown) + if paint_source == 'tick': + patch.setattr(monitor, 'refresh', lambda: True) + await asyncio.to_thread(monitor.tick) + else: + await asyncio.to_thread(CLITerminalMixin._paint_now, cli) done, _ = await asyncio.wait({task}, timeout=1) assert task in done, f'{name} remained hidden behind the monitor' await task