Compare commits
3 Commits
6e6890ebd9
...
1a5b6e25f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 |
@@ -33,8 +33,18 @@ from . import BottleSpec
|
|||||||
|
|
||||||
def mint_slug(spec: BottleSpec) -> str:
|
def mint_slug(spec: BottleSpec) -> str:
|
||||||
"""Return the bottle identity: the recorded identity for a resume,
|
"""Return the bottle identity: the recorded identity for a resume,
|
||||||
or a freshly minted one for a new start."""
|
or a freshly minted one for a new start.
|
||||||
return spec.identity or bottle_identity(spec.agent_name)
|
|
||||||
|
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(
|
def write_launch_metadata(
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ from ..agent_provider import runtime_for
|
|||||||
from ..backend import (
|
from ..backend import (
|
||||||
Bottle,
|
Bottle,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
|
enumerate_active_agents,
|
||||||
get_bottle_backend,
|
get_bottle_backend,
|
||||||
known_backend_names,
|
known_backend_names,
|
||||||
)
|
)
|
||||||
|
from ..backend.docker import util as docker_mod
|
||||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from ..bottle_state import (
|
from ..bottle_state import (
|
||||||
cleanup_state,
|
cleanup_state,
|
||||||
@@ -74,6 +76,7 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
backend_name: str | None = args.backend
|
backend_name: str | None = args.backend
|
||||||
|
|
||||||
label, color = tui.name_color_modal(default_label=agent_name)
|
label, color = tui.name_color_modal(default_label=agent_name)
|
||||||
|
label, color = _resolve_unique_label(label, color)
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -191,6 +194,21 @@ def _identity_from_plan(plan: object) -> str:
|
|||||||
return getattr(plan, "slug", "")
|
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:
|
def _text_prompt_yes() -> bool:
|
||||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||||
controlling tty via stderr prompt + tty-line read."""
|
controlling tty via stderr prompt + tty-line read."""
|
||||||
|
|||||||
+16
-8
@@ -243,11 +243,15 @@ _COLOR_NONE = "(none)"
|
|||||||
def name_color_modal(
|
def name_color_modal(
|
||||||
default_label: str,
|
default_label: str,
|
||||||
*,
|
*,
|
||||||
|
disclaimer: str = "",
|
||||||
tty_path: str = "/dev/tty",
|
tty_path: str = "/dev/tty",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Present a two-step curses modal: first edit the agent label,
|
"""Present a two-step curses modal: first edit the agent label,
|
||||||
then optionally pick a color.
|
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
|
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
||||||
color name strings or ``""`` for no color. Falls back to
|
color name strings or ``""`` for no color. Falls back to
|
||||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
``(default_label, "")`` on any error (terminal too small, not a tty).
|
||||||
@@ -259,14 +263,14 @@ def name_color_modal(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
fd_dup = os.dup(tty_fd.fileno())
|
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
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
return default_label, ""
|
return default_label, ""
|
||||||
finally:
|
finally:
|
||||||
tty_fd.close()
|
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
|
import io
|
||||||
orig_stdin = sys.__stdin__
|
orig_stdin = sys.__stdin__
|
||||||
orig_stdout = sys.__stdout__
|
orig_stdout = sys.__stdout__
|
||||||
@@ -281,7 +285,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
screen.keypad(True)
|
screen.keypad(True)
|
||||||
try:
|
try:
|
||||||
label = _label_step(screen, default_label)
|
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
||||||
color = _color_step(screen, label)
|
color = _color_step(screen, label)
|
||||||
finally:
|
finally:
|
||||||
screen.keypad(False)
|
screen.keypad(False)
|
||||||
@@ -294,14 +298,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
return label, color
|
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
|
"""Step 1: edit the label. First printable key replaces the
|
||||||
pre-fill; subsequent keys append. Enter confirms."""
|
pre-fill; subsequent keys append. Enter confirms."""
|
||||||
text = default_label
|
text = default_label
|
||||||
replaced = False # True once the user has typed their first char
|
replaced = False # True once the user has typed their first char
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_render_label(screen, text)
|
_render_label(screen, text, disclaimer=disclaimer)
|
||||||
try:
|
try:
|
||||||
key = screen.getch()
|
key = screen.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -325,7 +329,7 @@ def _label_step(screen: Any, default_label: str) -> str:
|
|||||||
text += chr(key)
|
text += chr(key)
|
||||||
|
|
||||||
|
|
||||||
def _render_label(screen: Any, text: str) -> None:
|
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
||||||
screen.erase()
|
screen.erase()
|
||||||
rows, cols = screen.getmaxyx()
|
rows, cols = screen.getmaxyx()
|
||||||
sep = "─" * min(cols - 1, 40)
|
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, 1, 0, sep)
|
||||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
||||||
_addstr_safe(screen, 3, 0, sep)
|
_addstr_safe(screen, 3, 0, sep)
|
||||||
if rows > 5:
|
row = 4
|
||||||
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
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()
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot_bottle import bottle_state
|
|||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import BottleSpec
|
||||||
from bot_bottle.backend.docker import DockerBottleBackend
|
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.backend.smolmachines import SmolmachinesBottleBackend
|
||||||
from bot_bottle.manifest import Manifest
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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.start as start_mod
|
||||||
import bot_bottle.cli.tui as tui_mod
|
import bot_bottle.cli.tui as tui_mod
|
||||||
|
from bot_bottle.backend import ActiveAgent
|
||||||
|
|
||||||
|
|
||||||
def _make_manifest(agent_names: list[str]):
|
def _make_manifest(agent_names: list[str]):
|
||||||
@@ -133,5 +134,63 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
self._launch_mock.assert_not_called()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user