refactor(dashboard): extract _route_op_to_right_pane helper
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m7s

Both `_new_agent_flow` (bringup) and `_stop_bottle_flow`
(teardown) were doing the same five-step dance: open the log
path, mkdir parents, empty the file, ensure the right pane is
tailing it, redirect fd 2 to the same file. Extract into a
context manager:

    with _route_op_to_right_pane(tmux_state, slug, log_name) as routed:
        if routed:
            <run op>

Yields True when routing succeeded (fd 2 redirected, pane
tailing), False on fallback conditions (not in tmux, no
tmux_state, or tmux failed to spawn a pane). The fallback
paths still differ between callers — bringup follows up
with `_attach_in_tmux`, teardown does the curses-endwin
compose-down — so the helper stops at "is stderr routed
or not" and lets callers branch from there. Net diff:
~60 lines deleted, the routing-to-right-pane concept now
lives in one place.
This commit is contained in:
2026-05-26 15:13:20 -04:00
parent 933d8cf6c3
commit 9646bc1c4c
+74 -60
View File
@@ -710,33 +710,24 @@ def _stop_bottle_flow(
except BaseException:
pass
if _in_tmux() and tmux_state is not None:
# Mirror the bringup path: route compose-down + state-
# settle output into the right pane via `tail -F` + fd-2
# redirect. Reuses any existing right pane (which is
# probably the agent's own claude session) via
# _ensure_right_pane → respawn-pane. Tail-F handles the
# state-dir-being-removed-mid-tail case gracefully.
log_path = bottle_state_dir(slug) / "teardown.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("")
pane_id = _ensure_right_pane(
tmux_state, ["tail", "-F", str(log_path)],
)
if pane_id is not None:
tmux_state["slug"] = slug
try:
with _redirect_stderr_to_file(log_path):
_do_teardown()
except BaseException:
pass
settle_state(identity)
# Right pane keeps tailing the (now-removed) log — its
# final buffered output stays visible until the next
# attach respawns it.
# Mirror the bringup path's stderr → right-pane routing.
# Reuses any existing right pane (which is probably the
# agent's own claude session) via `_ensure_right_pane`; the
# final buffered output stays visible after settle_state
# removes the state dir (tail-F handles file removal).
try:
with _route_op_to_right_pane(
tmux_state, slug, "teardown.log",
) as routed:
if routed:
_do_teardown()
except BaseException:
pass
if routed:
settle_state(identity)
if tmux_state is not None:
tmux_state["slug"] = None
return f"[{slug}] stopped"
# tmux failed; fall through to the curses-endwin path.
return f"[{slug}] stopped"
# Non-tmux: compose-down output writes to the dashboard's
# terminal directly. Drop curses so the lines render cleanly,
@@ -863,6 +854,42 @@ def _tmux_respawn_pane(pane_id: str, argv: list[str]) -> bool:
return result.returncode == 0
@contextlib.contextmanager
def _route_op_to_right_pane(
tmux_state: dict | None,
slug: str,
log_name: str,
):
"""Run an operation with its stderr routed into the right
tmux pane via `tail -F`.
Yields True when routing succeeded — the with-block runs
with fd 2 redirected to `state/<slug>/<log_name>` and the
right pane is tailing the same file. Yields False otherwise
(not in tmux, no tmux_state, or tmux failed to spawn the
pane) — the caller decides how to fall back.
Used identically by the bringup flow (log_name='bringup.log')
and the teardown flow ('teardown.log'). The fallback paths
differ between callers — bringup follows up with
`_attach_in_tmux`, teardown does the curses-endwin direct
compose-down — so the helper stops at "stderr is now routed
or it isn't" and lets callers branch from there."""
if not _in_tmux() or tmux_state is None:
yield False
return
log_path = bottle_state_dir(slug) / log_name
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("") # empty so tail starts clean
pane_id = _ensure_right_pane(tmux_state, ["tail", "-F", str(log_path)])
if pane_id is None:
yield False
return
tmux_state["slug"] = slug
with _redirect_stderr_to_file(log_path):
yield True
def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
"""Run `argv` in the dashboard's right pane — respawn an
existing tracked pane if one is alive, split-window to
@@ -1021,41 +1048,28 @@ def _new_agent_flow(
backend = get_bottle_backend()
if _in_tmux() and tmux_state is not None:
# PRD 0021 follow-up: route the bringup output into
# the right pane. `_ensure_right_pane` reuses any
# existing right pane via respawn — so a second
# new-agent start doesn't pile up a fresh pane — and
# only splits when no pane is tracked or the tracked
# one was closed. fd 2 is redirected to the same log
# the tail is following, so both Python `info()`
# writes AND subprocess (docker compose up, network
# creates, provision) stderr stream into the right
# pane. Curses stays up the whole time.
log_path = bottle_state_dir(plan.slug) / "bringup.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("") # empty so tail starts clean
pane_id = _ensure_right_pane(
tmux_state, ["tail", "-F", str(log_path)],
# PRD 0021 follow-up: in tmux, route the launch step's
# stderr (Python info() + subprocess inheritors) into
# the right pane via tail. On success, fall through to
# `_attach_in_tmux` which respawns the same pane with
# claude. On failure, fall through to the curses-endwin
# handoff so the operator still gets a session.
try:
with _route_op_to_right_pane(
tmux_state, plan.slug, "bringup.log",
) as routed:
if routed:
cm = backend.launch(plan)
bottle = cm.__enter__()
except BaseException:
settle_state(identity)
raise
if routed:
bottles[plan.slug] = (cm, bottle, identity)
return _attach_in_tmux(
stdscr, bottle, plan.slug,
resume=False, tmux_state=tmux_state,
)
if pane_id is not None:
tmux_state["slug"] = plan.slug
try:
with _redirect_stderr_to_file(log_path):
cm = backend.launch(plan)
bottle = cm.__enter__()
except BaseException:
settle_state(identity)
raise
bottles[plan.slug] = (cm, bottle, identity)
# Respawn the same pane: tail → claude session.
return _attach_in_tmux(
stdscr, bottle, plan.slug,
resume=False, tmux_state=tmux_state,
)
# pane creation failed (no tmux binary, server died) →
# fall through to the curses-endwin handoff so the
# operator still gets a session.
# Launch step writes to stderr (image build, network create,
# compose up). Get out of curses' way for the duration so