diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index c316173..549a466 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -33,8 +33,18 @@ from . import BottleSpec def mint_slug(spec: BottleSpec) -> str: """Return the bottle identity: the recorded identity for a resume, - or a freshly minted one for a new start.""" - return spec.identity or bottle_identity(spec.agent_name) + or a freshly minted one for a new start. + + When a label is provided it becomes the full slug (no random suffix), + so two launches with the same label collide by design. When no label + is given the identity is minted with a random suffix to avoid + collisions between anonymous launches of the same agent.""" + if spec.identity: + return spec.identity + if spec.label: + from .docker import util as docker_mod + return docker_mod.slugify(spec.label) + return bottle_identity(spec.agent_name) def write_launch_metadata( diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index a658b56..55a47ac 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -20,9 +20,11 @@ from ..agent_provider import runtime_for from ..backend import ( Bottle, BottleSpec, + enumerate_active_agents, get_bottle_backend, known_backend_names, ) +from ..backend.docker import util as docker_mod from ..backend.docker.bottle_plan import DockerBottlePlan from ..bottle_state import ( cleanup_state, @@ -74,6 +76,7 @@ def cmd_start(argv: list[str]) -> int: backend_name: str | None = args.backend label, color = tui.name_color_modal(default_label=agent_name) + label, color = _resolve_unique_label(label, color) spec = BottleSpec( manifest=manifest, @@ -191,6 +194,21 @@ def _identity_from_plan(plan: object) -> str: return getattr(plan, "slug", "") +def _resolve_unique_label(label: str, color: str) -> tuple[str, str]: + """Re-prompt with a disclaimer until the label's slug is not already + in use among running bottles. Passes through unchanged when no + collision is found on the first check.""" + while True: + slug_candidate = docker_mod.slugify(label) + active_slugs = {a.slug for a in enumerate_active_agents()} + if slug_candidate not in active_slugs: + return label, color + label, color = tui.name_color_modal( + default_label=label, + disclaimer=f'"{label}" is already in use', + ) + + def _text_prompt_yes() -> bool: """Default `prompt_yes` for CLI use: reads y/N from the controlling tty via stderr prompt + tty-line read.""" diff --git a/bot_bottle/cli/tui.py b/bot_bottle/cli/tui.py index 4ecac36..57cced3 100644 --- a/bot_bottle/cli/tui.py +++ b/bot_bottle/cli/tui.py @@ -243,11 +243,15 @@ _COLOR_NONE = "(none)" def name_color_modal( default_label: str, *, + disclaimer: str = "", tty_path: str = "/dev/tty", ) -> tuple[str, str]: """Present a two-step curses modal: first edit the agent label, then optionally pick a color. + ``disclaimer`` is shown below the input field — use it to surface + an error from a previous attempt (e.g. name already in use). + Returns ``(label, color)`` where ``color`` is one of the 16 ANSI color name strings or ``""`` for no color. Falls back to ``(default_label, "")`` on any error (terminal too small, not a tty). @@ -259,14 +263,14 @@ def name_color_modal( try: fd_dup = os.dup(tty_fd.fileno()) - return _run_name_color(default_label, tty_fd=fd_dup) + return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer) except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return default_label, "" finally: tty_fd.close() -def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]: +def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]: import io orig_stdin = sys.__stdin__ orig_stdout = sys.__stdout__ @@ -281,7 +285,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]: curses.cbreak() screen.keypad(True) try: - label = _label_step(screen, default_label) + label = _label_step(screen, default_label, disclaimer=disclaimer) color = _color_step(screen, label) finally: screen.keypad(False) @@ -294,14 +298,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]: return label, color -def _label_step(screen: Any, default_label: str) -> str: +def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str: """Step 1: edit the label. First printable key replaces the pre-fill; subsequent keys append. Enter confirms.""" text = default_label replaced = False # True once the user has typed their first char while True: - _render_label(screen, text) + _render_label(screen, text, disclaimer=disclaimer) try: key = screen.getch() except KeyboardInterrupt: @@ -325,7 +329,7 @@ def _label_step(screen: Any, default_label: str) -> str: text += chr(key) -def _render_label(screen: Any, text: str) -> None: +def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None: screen.erase() rows, cols = screen.getmaxyx() sep = "─" * min(cols - 1, 40) @@ -333,8 +337,12 @@ def _render_label(screen: Any, text: str) -> None: _addstr_safe(screen, 1, 0, sep) _addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE) _addstr_safe(screen, 3, 0, sep) - if rows > 5: - _addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM) + row = 4 + if disclaimer and rows > row + 1: + _addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD) + row += 1 + if rows > row + 1: + _addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM) screen.refresh() diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index 3fab237..ee673a1 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -16,6 +16,7 @@ from bot_bottle import bottle_state from bot_bottle import supervise from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import DockerBottleBackend +from bot_bottle.backend.resolve_common import mint_slug from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend from bot_bottle.manifest import Manifest @@ -115,5 +116,36 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase): ) +class TestMintSlug(unittest.TestCase): + def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec: + manifest = _manifest() + return BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp", + label=label, + identity=identity, + ) + + def test_no_label_uses_agent_name_with_random_suffix(self) -> None: + slug = mint_slug(self._spec(label="")) + self.assertTrue(slug.startswith("demo-"), slug) + # random suffix present — slug is longer than just "demo" + self.assertGreater(len(slug), len("demo-")) + + def test_label_becomes_exact_slug(self) -> None: + slug = mint_slug(self._spec(label="my-run")) + self.assertEqual("my-run", slug) + + def test_label_with_spaces_slugified_no_suffix(self) -> None: + slug = mint_slug(self._spec(label="My Feature Run")) + self.assertEqual("my-feature-run", slug) + + def test_identity_takes_precedence_over_label(self) -> None: + slug = mint_slug(self._spec(label="my-run", identity="fixed-id")) + self.assertEqual("fixed-id", slug) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_cli_start_selector.py b/tests/unit/test_cli_start_selector.py index d34ee0e..d0876a1 100644 --- a/tests/unit/test_cli_start_selector.py +++ b/tests/unit/test_cli_start_selector.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch import bot_bottle.cli.start as start_mod import bot_bottle.cli.tui as tui_mod +from bot_bottle.backend import ActiveAgent def _make_manifest(agent_names: list[str]): @@ -133,5 +134,63 @@ class TestCmdStartSelector(unittest.TestCase): self._launch_mock.assert_not_called() +def _active_agent(slug: str) -> ActiveAgent: + return ActiveAgent( + backend_name="docker", + slug=slug, + agent_name="demo", + started_at="2026-01-01T00:00:00+00:00", + services=(), + ) + + +class TestCmdStartLabelCollision(unittest.TestCase): + """cmd_start re-prompts when the label's slug is already running.""" + + def setUp(self): + self._manifest = _make_manifest(["researcher"]) + patch("bot_bottle.cli.start.Manifest.resolve", return_value=self._manifest).start() + self._launch_mock = patch( + "bot_bottle.cli.start._launch_bottle", return_value=0, + ).start() + self.addCleanup(patch.stopall) + + def test_no_collision_proceeds_without_reprompt(self): + with ( + patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal, + patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]), + ): + rc = start_mod.cmd_start(["researcher"]) + self.assertEqual(0, rc) + modal.assert_called_once() + self._launch_mock.assert_called_once() + + def test_collision_reprompts_with_disclaimer(self): + collision_agent = _active_agent("researcher") + call_count = 0 + + def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]: + nonlocal call_count + call_count += 1 + if call_count == 1: + return "researcher", "" + return "researcher-2", "" + + with ( + patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal, + patch( + "bot_bottle.cli.start.enumerate_active_agents", + side_effect=[[collision_agent], []], + ), + ): + rc = start_mod.cmd_start(["researcher"]) + + self.assertEqual(0, rc) + self.assertEqual(2, modal.call_count) + second_call_kwargs = modal.call_args_list[1][1] + self.assertIn("researcher", second_call_kwargs.get("disclaimer", "")) + self.assertIn("already in use", second_call_kwargs.get("disclaimer", "")) + + if __name__ == "__main__": unittest.main()