From 3ed3745982ef61cedf9a2e996bed3e6da48f8990 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 03:46:57 -0400 Subject: [PATCH] feat(dashboard): `x` stops a dashboard-owned bottle (PRD 0020 chunk 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PRD 0020 chunk. `x` on a focused agents-pane row tears down the selected bottle if the dashboard owns it (started via the chunk-2 `n` flow): pops `(cm, bottle, identity)` from the main loop's bottles map, snapshots the transcript best-effort, calls `cm.__exit__(None, None, None)` to drive the existing compose-down + network-remove sequence, then `settle_state` to honor any pre-existing preserve marker. On a non-owned slug (discovered via `list_active_slugs` but not in the dashboard's bottles dict — i.e., previous-dashboard or external `./cli.py start` bottle), `x` is a no-op with a status hint pointing at `./cli.py cleanup`. Matches the PRD's cross-dashboard re-attach model: the dashboard can re-attach either kind, but can only tear down its own. The PRD's chunk 5 ("quit-cleanup") is satisfied by the existing no-op behavior of `q` — per the user's resolved-question answer, quit leaves bottles running unchanged. No code change needed for that. Footer surfaces `[x] stop`. 465 unit tests pass (1 new for the non-owned no-op path; the owned path is integration territory because it drives a real compose-down). --- claude_bottle/cli/dashboard.py | 56 ++++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 18 +++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 3db7069..3000f69 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -650,6 +650,46 @@ def _bottle_for_slug( return synth, (prompt_path or "") +def _stop_bottle_flow( + stdscr: "curses._CursesWindow", + bottles: dict, + slug: str, +) -> str: + """Explicit per-bottle teardown (PRD 0020 chunk 4). Pops the + (cm, bottle, identity) tuple from the dashboard's bottles + map, snapshots the transcript best-effort, drives the launch + context's __exit__ (compose down + network remove), and + settles the state dir. A non-owned slug is a no-op with a + hint pointing at `./cli.py cleanup`.""" + if slug not in bottles: + return ( + f"[{slug}] not dashboard-owned — use ./cli.py cleanup" + ) + cm, _bottle, identity = bottles.pop(slug) + # compose-down writes to stderr; drop curses so the lines + # render cleanly. Same pattern as the attach handoff. + curses.endwin() + try: + # Best-effort snapshot before teardown so the operator + # can still inspect the agent's last state via the + # preserved transcript dir even after explicit stop. + # exit_code=0 → no auto-preserve; the operator's + # existing preserve marker (if any) is honored by + # settle_state below. + try: + capture_session_state(identity, exit_code=0) + except BaseException: + pass + try: + cm.__exit__(None, None, None) + except BaseException: + pass + finally: + stdscr.refresh() + settle_state(identity) + return f"[{slug}] stopped" + + def _attach_to_bottle( stdscr: "curses._CursesWindow", bottle, @@ -927,10 +967,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: if focus == PANE_AGENTS: # j/k/arrow navigate the agents list. Enter re-attaches - # to the focused bottle (PRD 0020 chunk 3) — works for - # both dashboard-owned bottles and bottles discovered - # via `list_active_slugs` (the synth path resolves the - # in-container claude target from the slug). + # (PRD 0020 chunk 3); `x` explicitly stops a + # dashboard-owned bottle (chunk 4). if key in (curses.KEY_DOWN, ord("j")): selected_agent = min(selected_agent + 1, max(0, len(agents) - 1)) elif key in (curses.KEY_UP, ord("k")): @@ -943,6 +981,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: manifest = manifest_cache[0] # may be None; that's ok bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest) status_line = _attach_to_bottle(stdscr, bottle, target.slug) + elif key == ord("x"): + target = _selected_agent(focus, agents, selected_agent) + if target is None: + status_line = "no agent selected" + else: + status_line = _stop_bottle_flow( + stdscr, bottles, target.slug, + ) continue if not pending: @@ -1082,7 +1128,7 @@ def _render( footer = ( "[n] new [Tab] switch [j/k] move " - "[Enter] view/attach [a/m/r] proposal [e/p] edit [q] quit" + "[Enter] view/attach [x] stop [a/m/r] proposal [e/p] edit [q] quit" ) stdscr.hline(h - 2, 0, curses.ACS_HLINE, w) stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM) diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 331e595..11486c6 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,24 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +class TestStopBottleFlow(unittest.TestCase): + """Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned + path is the one safe to test without curses + docker — the + owned path drives `cm.__exit__` against a real launch context + and belongs in integration tests.""" + + def test_non_owned_returns_cleanup_hint(self): + # stdscr is None here on purpose — the non-owned branch + # returns before any curses call. + msg = dashboard._stop_bottle_flow( + stdscr=None, # type: ignore[arg-type] + bottles={}, + slug="ghost-zzz", + ) + self.assertIn("not dashboard-owned", msg) + self.assertIn("./cli.py cleanup", msg) + + class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase): """Chunk-4 contract: the edit flow refuses when the selected agent doesn't have the required sidecar running. The discover-