fix(dashboard): reuse existing right pane on new-agent start
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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user