Compare commits

..

3 Commits

Author SHA1 Message Date
didericis-claude f837211bf8 refactor: scan filenames at resolve, parse only selected agent at preflight
lint / lint (push) Successful in 1m44s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 17s
Manifest.resolve() now returns an empty-dict manifest with only directory
paths recorded (home_md, cwd_md). No content is read from any .md file
until load_for_agent() is called for a specific agent at preflight.

- Manifest.from_md_dirs: scan-only, no frontmatter parsing
- Manifest.load_for_agent: parses the selected agent file and its bottle
  chain; works on eager (from_json_obj) manifests too by returning self
- Manifest.all_agent_names: scans filenames in lazy mode
- backend._validate: calls load_for_agent and propagates upgraded spec
- cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names
- manifest_extends.py: reverted to original (no partial-resolve helpers)
- manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir
- Tests updated to call load_for_agent before accessing agents/bottles;
  test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
2026-06-22 19:39:59 +00:00
didericis-claude 86fa2bf477 fix: resolve pyright reportUnusedImport in manifest_extends
Import ManifestError at module level from manifest_util (no circular
dep) and remove the redundant local imports from function bodies that
were shadowing it. ManifestBottle retains its local import pattern to
avoid the circular manifest ↔ manifest_extends dependency.
2026-06-22 19:39:12 +00:00
didericis-claude 4816733120 feat: defer broken manifest parse errors to preflight
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

Closes #236
2026-06-22 19:39:12 +00:00
5 changed files with 10 additions and 137 deletions
+2 -12
View File
@@ -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(
-18
View File
@@ -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
View File
@@ -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()
-32
View File
@@ -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()
-59
View File
@@ -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()