diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index d465a45..40d72fe 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -13,6 +13,7 @@ import time from collections.abc import Callable from pathlib import Path +from typing import TypeVar from rich.text import Text from textual import work @@ -62,6 +63,9 @@ def _engine_possibly_live(run_dir: Path) -> bool: return live == "unknown" and runs.read_pid(run_dir) is not None +_T = TypeVar("_T") + + class BmadLoopApp(App[None]): TITLE = "bmad-loop" @@ -154,6 +158,21 @@ def _mux_missing(self) -> bool: self.notify("multiplexer backend unavailable — launch/attach disabled", severity="error") return True + def _mux_guarded(self, probe: Callable[[], _T]) -> tuple[bool, _T | None]: + """Run a raiser-side multiplexer *read* probe from a foreground action + handler, converting a transport failure into an error toast. Returns + (ok, value); when ok is False a MultiplexerError was caught and toasted + and the handler must abort — a backend hiccup after the availability + pre-gate fails the action soft instead of crashing the TUI. Foreground + only: worker threads marshal notify() via call_from_thread (see + _cleanup_sessions_worker), and launch-layer failures convert to + LaunchError (see launch._ensure_ctl_session).""" + try: + return True, probe() + except MultiplexerError as e: + self.notify(str(e), severity="error") + return False, None + def _guarded(self, go: Callable[[], None]) -> None: """Pre-launch guard mirroring the CLI: clean worktree required, plus a confirm when another engine is already live.""" @@ -353,7 +372,9 @@ def action_attach(self) -> None: return session = runs.session_name(run_id) window = launch.ctl_window(run_id) - agent_live = launch.session_exists(session) + ok, agent_live = self._mux_guarded(lambda: launch.session_exists(session)) + if not ok: + return # A sweep blocked on a decision prompt has no agent session — the # human answers in the orchestrator's ctl window. Otherwise prefer the # live agent session, falling back to the ctl window between sessions. @@ -378,10 +399,8 @@ def action_attach(self) -> None: self._attach_to_target(target) def _attach_to_target(self, target: str, return_window: str | None = None) -> None: - try: - argv = runs.attach_target_argv(target) - except MultiplexerError as e: - self.notify(str(e), severity="error") + ok, argv = self._mux_guarded(lambda: runs.attach_target_argv(target)) + if not ok: return # Backend-honest inside-the-multiplexer probe (current_pane_id() is None # outside): inside, attach_target_argv returned the fire-and-forget @@ -907,7 +926,17 @@ def _cleanup_sessions_worker(self) -> None: # killed and unknown come from prune_sessions' single partition sample, # so the warning below only ever names sessions that were actually pruned killed, _live, unknown = runs.prune_sessions(self.project) - windows = launch.prune_ctl_windows(self.project) + # prune_ctl_windows probes has_session on the shared ctl session, a + # raiser-side call; on a worker thread the toast must be marshalled, and + # notify() must not be called directly (see _mux_guarded — foreground only). + try: + windows = launch.prune_ctl_windows(self.project) + except MultiplexerError as e: + # prune_sessions already killed the agent sessions above; surface the + # ctl-window failure but keep reporting that completed work (and the + # unknown-pid warning) rather than swallowing it on an early return. + self.call_from_thread(self.notify, str(e), severity="error") + windows = [] if unknown: self.call_from_thread( self.notify, diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index efc7b34..b075c41 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -245,12 +245,16 @@ def prune_ctl_windows(project: Path) -> list[str]: def _ensure_ctl_session(project: Path) -> None: mux = get_multiplexer() - if mux.has_session(CTL_SESSION): - return + # has_session is raiser-side (a server-backed backend can fail the probe after + # the availability pre-gate). Keep it inside the try so a transport failure + # converts to LaunchError, which the TUI launch/resume/resolve handlers already + # catch — otherwise the raw MultiplexerError slips past them and crashes the app. try: + if mux.has_session(CTL_SESSION): + return mux.new_session(CTL_SESSION, project) except MultiplexerError as e: - raise LaunchError(f"multiplexer new-session failed: {e}") from e + raise LaunchError(f"multiplexer ctl-session setup failed: {e}") from e def cli_argv(*tail: str) -> list[str]: diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a138bf8..d7ca9d1 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -1184,6 +1184,39 @@ async def test_cleanup_unknown_sessions_notifies(project, monkeypatch): assert any("removed 1 session(s)" in m for m in notifications(app)) +async def test_cleanup_sessions_mux_error_notifies(project, monkeypatch): + # prune_ctl_windows probes has_session on the shared ctl session (raiser-side), + # so it can raise on a server-backed backend. The worker must marshal the error + # to a toast via call_from_thread without crashing on an unhandled worker + # exception — AND, because prune_sessions already killed the agent sessions + # before prune_ctl_windows ran, it must still report that completed work (the + # "removed N session(s)" summary and the unknown-pid warning), not swallow it. + from bmad_loop import runs + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(runs, "prune_sessions", lambda _p: (["odd-1"], [], {"odd-1"})) + + def boom(_p): + raise MultiplexerError("ctl window probe unreachable") + + monkeypatch.setattr(launch, "prune_ctl_windows", boom) + make_run(project.project, "20260611-100000-aaaa") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + await pilot.press("c") + await until(pilot, lambda: isinstance(app.screen, ConfirmModal)) + await pilot.click(await ready(pilot, "#ok")) + await until( + pilot, lambda: any("ctl window probe unreachable" in m for m in notifications(app)) + ) + # the ctl-window failure is surfaced, but the session pruning that already + # completed is still reported — not swallowed by an early return + await until(pilot, lambda: any("unverifiable engine pid" in m for m in notifications(app))) + assert any("removed 1 session(s)" in m for m in notifications(app)) + assert isinstance(app.screen, DashboardScreen) # worker failed soft, no crash + + async def test_resume_finished_run_refused(project, monkeypatch): monkeypatch.setattr(launch, "mux_available", lambda: True) make_run(project.project, "20260611-100000-aaaa", finished=True) @@ -1247,6 +1280,30 @@ def boom(_target): assert isinstance(app.screen, DashboardScreen) # the action failed soft +async def test_attach_session_probe_error_notifies(project, monkeypatch): + # session_exists probes has_session, a raiser-side call: on a server-backed + # backend it can raise after the availability pre-gate (server unreachable / + # torn down in between). action_attach routes it through _mux_guarded, so the + # TUI toasts the error and aborts the attach instead of crashing the app. + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "ctl_window", lambda run_id: None) + + def boom(_session): + raise MultiplexerError("session probe unreachable") + + monkeypatch.setattr(launch, "session_exists", boom) + make_run(project.project, "20260611-100000-aaaa") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + await until(pilot, lambda: dashboard(app).selected_run_id is not None) + await pilot.press("a") + await until( + pilot, lambda: any("session probe unreachable" in m for m in notifications(app)) + ) + assert isinstance(app.screen, DashboardScreen) # the action failed soft + + # ------------------------------------------------------- sweep decision flow diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 28737d7..b3411a7 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -203,6 +203,22 @@ def failing_run(argv, **kwargs): launch.start_run_detached(tmp_path, "RID") +def test_ensure_ctl_session_probe_failure_raises_launch_error(monkeypatch, tmp_path: Path): + # has_session is raiser-side: a transport failure (timeout / missing binary) on + # the ctl-session probe must convert to LaunchError so the TUI's launch/resume/ + # resolve handlers (which catch LaunchError) surface a toast instead of crashing + # on the raw MultiplexerError that would otherwise slip past their except clause. + def failing_run(argv, **kwargs): + if argv[1] == "has-session": + raise OSError("backend server not reachable") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(tmux_base.subprocess, "run", failing_run) + monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") + with pytest.raises(launch.LaunchError, match="ctl-session setup failed"): + launch.start_run_detached(tmp_path, "RID") + + def test_session_exists(monkeypatch): fake = FakeRun(has_session_rc=0) monkeypatch.setattr(tmux_base.subprocess, "run", fake)