Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0609813ba0 | |||
| 09e04359e3 | |||
| fe32f65fa4 | |||
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 |
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+16
-8
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]):
|
||||
@@ -134,5 +135,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()
|
||||
|
||||
Reference in New Issue
Block a user