Compare commits
5 Commits
2ff1730588
...
ae6d11f09d
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6d11f09d | |||
| 14d5c78370 | |||
| 832e92c7a6 | |||
| 3d179f18fc | |||
| 3ed3745982 |
@@ -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)
|
||||||
|
|||||||
@@ -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-
|
||||||
|
|||||||
Reference in New Issue
Block a user