fix(dashboard): reuse existing right pane on new-agent start
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m13s

PRD 0021 follow-up. The new-agent flow was calling a dedicated
`_tmux_split_pane_tail` that ALWAYS created a new pane —
so every `n` start spawned a fresh right pane next to any
existing one, accumulating panes instead of reusing them.

Replace with a generic `_ensure_right_pane(tmux_state, argv)`
that respawns the dashboard's tracked right pane if one is
alive, splits a new one only when none is tracked or the
tracked pane was closed. Both the new-agent tail-during-
bringup path AND the existing claude-attach path now route
through this helper.

Net effect: starting a second agent reuses the same right
pane — bringup tail replaces the prior claude session,
then claude (for the new agent) replaces the tail. Closing
the right pane manually via `C-b x` still triggers a fresh
split on the next attach.
This commit is contained in:
2026-05-26 14:50:56 -04:00
parent 83ec9669c9
commit 0936c40428
+56 -55
View File
@@ -778,34 +778,15 @@ def _redirect_stderr_to_file(path):
os.close(log_fd) os.close(log_fd)
def _tmux_split_pane_tail(log_path) -> str | None: def _tmux_split_pane_create(argv: list[str]) -> str | None:
"""Pre-create the right pane tailing `log_path` so the """Open a right pane running `argv` via `tmux split-window
`_new_agent_flow` launch step's redirected stderr streams -h`. Returns the new pane's id on success, None on any
into it. Returns the new pane's id or None on failure. failure (tmux missing, nonzero exit, empty stdout). Generic
The pane is later respawned with the claude session via over `argv` so both the tail-during-bringup path and the
`_tmux_respawn_pane`.""" claude-attach path can build on it."""
argv = _build_split_pane_argv(["tail", "-F", str(log_path)])
try: try:
result = subprocess.run( result = subprocess.run(
argv, capture_output=True, text=True, check=False, _build_split_pane_argv(argv),
)
except FileNotFoundError:
return None
if result.returncode != 0:
return None
return (result.stdout or "").strip() or None
def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None:
"""Open a right pane via `tmux split-window -h`. Returns the
new pane's id on success, None on any failure (tmux missing,
nonzero exit, empty stdout)."""
docker_argv = bottle.claude_docker_argv(
_claude_runtime_args(resume=resume),
)
try:
result = subprocess.run(
_build_split_pane_argv(docker_argv),
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
except FileNotFoundError: except FileNotFoundError:
@@ -816,15 +797,14 @@ def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None:
return pane_id or None return pane_id or None
def _tmux_respawn_pane(pane_id: str, bottle, *, resume: bool) -> bool: def _tmux_respawn_pane(pane_id: str, argv: list[str]) -> bool:
"""Replace the content of `pane_id` with a fresh claude """Replace the content of `pane_id` with `argv` via `tmux
session via `tmux respawn-pane -k`. Returns True on success.""" respawn-pane -k`. Returns True on success. Generic over
docker_argv = bottle.claude_docker_argv( `argv` so the same helper handles tail→claude transitions
_claude_runtime_args(resume=resume), and slug→slug claude transitions."""
)
try: try:
result = subprocess.run( result = subprocess.run(
_build_respawn_pane_argv(pane_id, docker_argv), _build_respawn_pane_argv(pane_id, argv),
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
except FileNotFoundError: except FileNotFoundError:
@@ -832,6 +812,29 @@ def _tmux_respawn_pane(pane_id: str, bottle, *, resume: bool) -> bool:
return result.returncode == 0 return result.returncode == 0
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
create one otherwise. Updates `tmux_state['pane_id']` and
returns the pane id on success, None on failure.
This is the single place where "respawn or create" lives —
used by `_attach_in_tmux` for claude sessions AND by
`_new_agent_flow` for the bringup-log tail. Without this,
every new-agent start would pile up a fresh right pane
instead of reusing the one already next to the dashboard."""
pane_id = tmux_state.get("pane_id")
if pane_id and _tmux_pane_exists(pane_id):
if _tmux_respawn_pane(pane_id, argv):
return pane_id
# respawn failed — fall through to create a fresh split.
tmux_state["pane_id"] = None
new_pane_id = _tmux_split_pane_create(argv)
if new_pane_id is not None:
tmux_state["pane_id"] = new_pane_id
return new_pane_id
def _tmux_pane_exists(pane_id: str) -> bool: def _tmux_pane_exists(pane_id: str) -> bool:
"""True when `pane_id` appears in `tmux list-panes -F """True when `pane_id` appears in `tmux list-panes -F
'#{pane_id}'`. Used before respawn-pane to detect a pane the '#{pane_id}'`. Used before respawn-pane to detect a pane the
@@ -883,22 +886,16 @@ def _attach_in_tmux(
session. Mutates `tmux_state` ({'pane_id': str|None, session. Mutates `tmux_state` ({'pane_id': str|None,
'slug': str|None}) so the main loop can track which slug is 'slug': str|None}) so the main loop can track which slug is
in the right pane (used by the agents-pane indicator + the in the right pane (used by the agents-pane indicator + the
explicit-stop hook in chunk 4).""" explicit-stop hook)."""
pane_id = tmux_state.get("pane_id") docker_argv = bottle.claude_docker_argv(
if pane_id and _tmux_pane_exists(pane_id): _claude_runtime_args(resume=resume),
if _tmux_respawn_pane(pane_id, bottle, resume=resume): )
tmux_state["slug"] = slug pane_id = _ensure_right_pane(tmux_state, docker_argv)
return f"[{slug}] in right pane" if pane_id is None:
# respawn failed — fall through to create a fresh split.
tmux_state["pane_id"] = None
new_pane_id = _tmux_split_pane_create(bottle, resume=resume)
if new_pane_id is None:
# tmux failed (missing binary, server died, size error). # tmux failed (missing binary, server died, size error).
# One status-line failover to the curses handoff so the # One status-line failover to the curses handoff so the
# operator still gets a session. # operator still gets a session.
return _attach_via_handoff(stdscr, bottle, slug, resume=resume) return _attach_via_handoff(stdscr, bottle, slug, resume=resume)
tmux_state["pane_id"] = new_pane_id
tmux_state["slug"] = slug tmux_state["slug"] = slug
return f"[{slug}] in right pane" return f"[{slug}] in right pane"
@@ -974,19 +971,23 @@ def _new_agent_flow(
backend = get_bottle_backend() backend = get_bottle_backend()
if _in_tmux() and tmux_state is not None: if _in_tmux() and tmux_state is not None:
# PRD 0021 follow-up: pre-create the right pane tailing # PRD 0021 follow-up: route the bringup output into
# state/<slug>/bringup.log, redirect fd 2 to that log # the right pane. `_ensure_right_pane` reuses any
# during launch, then respawn the pane with claude. # existing right pane via respawn — so a second
# Net effect: compose-up + provision output streams into # new-agent start doesn't pile up a fresh pane — and
# the right pane (where claude will live), the dashboard # only splits when no pane is tracked or the tracked
# pane stays uncluttered, and curses doesn't need to be # one was closed. fd 2 is redirected to the same log
# taken down at all. # 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 = bottle_state_dir(plan.slug) / "bringup.log"
log_path.parent.mkdir(parents=True, exist_ok=True) log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("") # empty so tail starts clean log_path.write_text("") # empty so tail starts clean
pane_id = _tmux_split_pane_tail(log_path) pane_id = _ensure_right_pane(
tmux_state, ["tail", "-F", str(log_path)],
)
if pane_id is not None: if pane_id is not None:
tmux_state["pane_id"] = pane_id
tmux_state["slug"] = plan.slug tmux_state["slug"] = plan.slug
try: try:
with _redirect_stderr_to_file(log_path): with _redirect_stderr_to_file(log_path):
@@ -996,7 +997,7 @@ def _new_agent_flow(
settle_state(identity) settle_state(identity)
raise raise
bottles[plan.slug] = (cm, bottle, identity) bottles[plan.slug] = (cm, bottle, identity)
# Respawn the right pane: tail → claude session. # Respawn the same pane: tail → claude session.
return _attach_in_tmux( return _attach_in_tmux(
stdscr, bottle, plan.slug, stdscr, bottle, plan.slug,
resume=False, tmux_state=tmux_state, resume=False, tmux_state=tmux_state,