Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f837211bf8 | |||
| 86fa2bf477 | |||
| 4816733120 |
@@ -33,18 +33,8 @@ 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,11 +20,9 @@ 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,
|
||||||
@@ -76,7 +74,6 @@ 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,
|
||||||
@@ -194,21 +191,6 @@ 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."""
|
||||||
|
|||||||
+8
-16
@@ -243,15 +243,11 @@ _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).
|
||||||
@@ -263,14 +259,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, disclaimer=disclaimer)
|
return _run_name_color(default_label, tty_fd=fd_dup)
|
||||||
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, disclaimer: str = "") -> tuple[str, str]:
|
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
||||||
import io
|
import io
|
||||||
orig_stdin = sys.__stdin__
|
orig_stdin = sys.__stdin__
|
||||||
orig_stdout = sys.__stdout__
|
orig_stdout = sys.__stdout__
|
||||||
@@ -285,7 +281,7 @@ def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") ->
|
|||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
screen.keypad(True)
|
screen.keypad(True)
|
||||||
try:
|
try:
|
||||||
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
label = _label_step(screen, default_label)
|
||||||
color = _color_step(screen, label)
|
color = _color_step(screen, label)
|
||||||
finally:
|
finally:
|
||||||
screen.keypad(False)
|
screen.keypad(False)
|
||||||
@@ -298,14 +294,14 @@ def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") ->
|
|||||||
return label, color
|
return label, color
|
||||||
|
|
||||||
|
|
||||||
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
def _label_step(screen: Any, default_label: 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, disclaimer=disclaimer)
|
_render_label(screen, text)
|
||||||
try:
|
try:
|
||||||
key = screen.getch()
|
key = screen.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -329,7 +325,7 @@ def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str
|
|||||||
text += chr(key)
|
text += chr(key)
|
||||||
|
|
||||||
|
|
||||||
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
def _render_label(screen: Any, text: str) -> None:
|
||||||
screen.erase()
|
screen.erase()
|
||||||
rows, cols = screen.getmaxyx()
|
rows, cols = screen.getmaxyx()
|
||||||
sep = "─" * min(cols - 1, 40)
|
sep = "─" * min(cols - 1, 40)
|
||||||
@@ -337,12 +333,8 @@ def _render_label(screen: Any, text: str, *, disclaimer: 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)
|
||||||
row = 4
|
if rows > 5:
|
||||||
if disclaimer and rows > row + 1:
|
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
||||||
_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,7 +16,6 @@ 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
|
||||||
|
|
||||||
@@ -116,36 +115,5 @@ 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,7 +14,6 @@ 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]):
|
||||||
@@ -135,63 +134,5 @@ 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