Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions src/bmad_loop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/bmad_loop/tui/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
16 changes: 16 additions & 0 deletions tests/test_tui_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading