Compare commits

...

5 Commits

Author SHA1 Message Date
didericis ae6d11f09d fix(dashboard): use os._exit on quit so bottles survive the dashboard
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m9s
The `bottles` dict held `@contextmanager`-wrapped launch contexts.
On normal Python interpreter shutdown those context managers'
generators got GC'd, which raised GeneratorExit at the yield
point and ran the `finally` block — invoking each bottle's
teardown and tearing down the compose project. Net effect: `q`
WAS implicitly stopping every dashboard-launched bottle even
though the keypress handler just `return`'d.

`os._exit(0)` skips all Python-level cleanup (GC, atexit, etc.),
so the docker compose projects survive the dashboard exit
untouched. Curses gets explicit `endwin()` first because the
brutal exit skips curses.wrapper's normal terminal restoration.

Matches PRD 0020's resolved-question answer (`q` does NOT tear
down bottles; teardown is always explicit via `x` or
`./cli.py cleanup`).
2026-05-26 13:48:16 -04:00
didericis 14d5c78370 fix(attach): use --continue (no picker) instead of --resume
`--resume` alone surfaces claude's session picker even when only
one session exists. `--continue` jumps to the most recent session
non-interactively, which is the actual behavior the dashboard's
Enter re-attach wants for typical bottle-with-one-session cases.
2026-05-26 13:48:16 -04:00
didericis 832e92c7a6 feat(attach): pass --resume on dashboard re-attach
Re-entering a running bottle from the dashboard (Enter on the
agents pane) now invokes claude with `--resume` so the session
picks up the prior conversation history rather than starting a
fresh transcript. The first-attach paths (`./cli.py start` and
the dashboard's new-agent `n` flow) leave it off — the
transcript doesn't exist yet there.

`attach_claude` gains a `resume: bool = False` kwarg;
`_attach_to_bottle` in the dashboard passes `True`.
2026-05-26 13:48:16 -04:00
didericis 3d179f18fc Merge pull request 'feat(dashboard): x stops a dashboard-owned bottle' (#46) from chunk-4-explicit-stop into main
test / unit (push) Successful in 19s
test / integration (push) Successful in 1m8s
2026-05-26 13:48:03 -04:00
didericis 3ed3745982 feat(dashboard): x stops a dashboard-owned bottle (PRD 0020 chunk 4)
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m7s
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).
2026-05-26 03:46:57 -04:00
3 changed files with 117 additions and 12 deletions
+84 -8
View File
@@ -650,16 +650,61 @@ def _bottle_for_slug(
return synth, (prompt_path or "") 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( def _attach_to_bottle(
stdscr: "curses._CursesWindow", stdscr: "curses._CursesWindow",
bottle, bottle,
slug: str, slug: str,
) -> str: ) -> str:
"""Handoff: curses.endwin → attach claude → curses refresh. """Handoff: curses.endwin → attach claude → curses refresh.
Returns the post-attach status-line message.""" Re-entry into a running bottle from the dashboard always
passes `--resume` so claude picks up its prior conversation
rather than starting a fresh transcript — the first attach
happens via `_new_agent_flow` which sets up the transcript
in the first place. Returns the post-attach status-line
message."""
curses.endwin() curses.endwin()
try: try:
exit_code = attach_claude(bottle, remote_control=False) exit_code = attach_claude(bottle, remote_control=False, resume=True)
except BaseException: except BaseException:
stdscr.refresh() stdscr.refresh()
raise raise
@@ -815,6 +860,30 @@ def _try_init_green() -> int:
return 0 return 0
def _quit_without_teardown(bottles: dict) -> None:
"""Exit the dashboard process WITHOUT triggering Python's normal
cleanup of the `bottles` dict's context managers.
The dict holds `@contextmanager`-decorated objects whose
underlying generators have implicit close-on-GC behavior:
when Python's interpreter shutdown collects them, each
generator's `finally` block runs, which invokes that bottle's
teardown (`docker compose down`). PRD 0020 explicitly DOESN'T
want that — quitting the dashboard should leave running
bottles running. `os._exit` skips all Python-level cleanup
(GC, atexit, stdio flush, etc.), so the docker compose
projects survive the dashboard exit untouched.
The `bottles` arg is accepted for the explicit
documentation-of-intent — we're choosing not to close
these. Curses gets its terminal restored via the explicit
`endwin` below since `os._exit` doesn't run
curses.wrapper's finally."""
del bottles # nothing to do with it; the os._exit is the point
curses.endwin()
os._exit(0)
# PRD 0019 chunk 3: which pane the j/k/arrow keys move through. # PRD 0019 chunk 3: which pane the j/k/arrow keys move through.
# Tab toggles. The proposals pane is the default focus — proposal # Tab toggles. The proposals pane is the default focus — proposal
# action keys (a/m/r/Enter) require it; agent-scoped keys (e/p, # action keys (a/m/r/Enter) require it; agent-scoped keys (e/p,
@@ -895,7 +964,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
status_line = "" status_line = ""
if key in (ord("q"), 27): # q or ESC if key in (ord("q"), 27): # q or ESC
return _quit_without_teardown(bottles)
return # unreachable; _quit_without_teardown os._exit's
if key == 9: # Tab if key == 9: # Tab
focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS
continue continue
@@ -927,10 +997,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
if focus == PANE_AGENTS: if focus == PANE_AGENTS:
# j/k/arrow navigate the agents list. Enter re-attaches # j/k/arrow navigate the agents list. Enter re-attaches
# to the focused bottle (PRD 0020 chunk 3) — works for # (PRD 0020 chunk 3); `x` explicitly stops a
# both dashboard-owned bottles and bottles discovered # dashboard-owned bottle (chunk 4).
# via `list_active_slugs` (the synth path resolves the
# in-container claude target from the slug).
if key in (curses.KEY_DOWN, ord("j")): if key in (curses.KEY_DOWN, ord("j")):
selected_agent = min(selected_agent + 1, max(0, len(agents) - 1)) selected_agent = min(selected_agent + 1, max(0, len(agents) - 1))
elif key in (curses.KEY_UP, ord("k")): elif key in (curses.KEY_UP, ord("k")):
@@ -943,6 +1011,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
manifest = manifest_cache[0] # may be None; that's ok manifest = manifest_cache[0] # may be None; that's ok
bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest) bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest)
status_line = _attach_to_bottle(stdscr, bottle, target.slug) 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 continue
if not pending: if not pending:
@@ -1082,7 +1158,7 @@ def _render(
footer = ( footer = (
"[n] new [Tab] switch [j/k] move " "[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.hline(h - 2, 0, curses.ACS_HLINE, w)
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM) stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
+15 -4
View File
@@ -92,15 +92,22 @@ def prepare_with_preflight(
def attach_claude( def attach_claude(
bottle: Bottle, *, remote_control: bool = False, bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
) -> int: ) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks """Run claude inside `bottle` as an interactive session. Blocks
until the session ends; returns the claude process's exit code. until the session ends; returns the claude process's exit code.
`resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt) —
the right shape for the dashboard's Enter re-attach (PRD 0020
chunk 3), where a bottle typically has exactly one session.
First-attach paths (`./cli.py start`, the dashboard's new-agent
flow) leave it False.
Used as the inner step of `./cli.py start` (one-shot) and by the Used as the inner step of `./cli.py start` (one-shot) and by the
dashboard (PRD 0020), which calls it from inside a `curses.endwin dashboard, which calls it from inside a `curses.endwin → … →
→ … → stdscr.refresh()` handoff so the curses surface gets out stdscr.refresh()` handoff so the curses surface gets out of the
of the terminal's way while claude has it.""" terminal's way while claude has it."""
info( info(
"attaching interactive claude session " "attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
@@ -108,6 +115,10 @@ def attach_claude(
claude_args = ["--dangerously-skip-permissions"] claude_args = ["--dangerously-skip-permissions"]
if remote_control: if remote_control:
claude_args.append("--remote-control") claude_args.append("--remote-control")
if resume:
# `--continue` jumps straight to the most recent session
# without showing the picker `--resume` would surface.
claude_args.append("--continue")
return bottle.exec_claude(claude_args, tty=True) return bottle.exec_claude(claude_args, tty=True)
@@ -379,6 +379,24 @@ class TestBottleForSlug(unittest.TestCase):
self.assertEqual("", hint) 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): class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
"""Chunk-4 contract: the edit flow refuses when the selected """Chunk-4 contract: the edit flow refuses when the selected
agent doesn't have the required sidecar running. The discover- agent doesn't have the required sidecar running. The discover-