refactor(docker): share container-name candidate iterator

Both prepare-time probing and launch-time race-retry generated the
same `<base>, <base>-2, ..., <base>-N` sequence with their own copies
of the suffix arithmetic and the 99-cap. Extract the candidate stream
into docker/util.container_name_candidates and have both call sites
walk it; each keeps its own predicate (probe vs. retry).

Also bumps the cap into a named constant (MAX_CONTAINER_SUFFIX) so
the two error messages can't drift.
This commit is contained in:
2026-05-11 20:06:09 -04:00
parent c63d8e0f9d
commit 42c2e8108e
2 changed files with 38 additions and 28 deletions
+23 -27
View File
@@ -84,10 +84,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
default_container = f"claude-bottle-{slug}" default_container = f"claude-bottle-{slug}"
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "") pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
container_name = pinned_container or default_container
container_name_pinned = bool(pinned_container) container_name_pinned = bool(pinned_container)
suffix = 2
if container_name_pinned: if container_name_pinned:
container_name = pinned_container
if docker_mod.container_exists(container_name): if docker_mod.container_exists(container_name):
die( die(
f"container '{container_name}' already exists " f"container '{container_name}' already exists "
@@ -95,15 +94,17 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
f"Remove it with 'docker rm -f {container_name}' or unset the override." f"Remove it with 'docker rm -f {container_name}' or unset the override."
) )
else: else:
while docker_mod.container_exists(container_name): container_name = ""
container_name = f"{default_container}-{suffix}" for candidate in docker_mod.container_name_candidates(default_container):
suffix += 1 if not docker_mod.container_exists(candidate):
if suffix > 100: container_name = candidate
die( break
f"could not find a free container name after " if not container_name:
f"{default_container}-99; clean up old containers with " die(
f"'docker rm -f <name>'" f"could not find a free container name after "
) f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; "
f"clean up old containers with 'docker rm -f <name>'"
)
if agent.skills: if agent.skills:
self.validate_skills(list(agent.skills)) self.validate_skills(list(agent.skills))
@@ -247,31 +248,26 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
info(f"starting container {plan.container_name} from {plan.runtime_image}") info(f"starting container {plan.container_name} from {plan.runtime_image}")
container = plan.container_name name_idx = docker_args.index("--name") + 1
base_name = plan.container_name for candidate in docker_mod.container_name_candidates(plan.container_name):
suffix = 2 docker_args[name_idx] = candidate
while True:
run_result = subprocess.run( run_result = subprocess.run(
["docker", "run", *docker_args], ["docker", "run", *docker_args],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if run_result.returncode == 0: if run_result.returncode == 0:
return container return candidate
err_text = run_result.stderr err_text = run_result.stderr
if plan.container_name_pinned or "is already in use" not in err_text: if plan.container_name_pinned or "is already in use" not in err_text:
sys.stderr.write(err_text + "\n") sys.stderr.write(err_text + "\n")
die(f"docker run failed for container '{container}'") die(f"docker run failed for container '{candidate}'")
if suffix > 100: info(f"name conflict on {candidate}; retrying with next candidate")
die( die(
f"could not find a free container name after " f"could not find a free container name after "
f"{base_name}-99 retries; clean up old containers" f"{plan.container_name}-{docker_mod.MAX_CONTAINER_SUFFIX} retries; "
) f"clean up old containers"
container = f"{base_name}-{suffix}" )
suffix += 1
name_idx = docker_args.index("--name") + 1
docker_args[name_idx] = container
info(f"name conflict; retrying as {container}")
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, target)
+15 -1
View File
@@ -7,11 +7,25 @@ from __future__ import annotations
import re import re
import shutil import shutil
import subprocess import subprocess
from typing import Iterable from typing import Iterable, Iterator
from ...log import die, info from ...log import die, info
# Cap on the suffix the container-name conflict logic will try before
# giving up: base, base-2, ..., base-MAX_CONTAINER_SUFFIX.
MAX_CONTAINER_SUFFIX = 100
def container_name_candidates(base: str) -> Iterator[str]:
"""Yield `base`, then `base-2`, `base-3`, ... up to
`base-MAX_CONTAINER_SUFFIX`. Both the prepare-time probe and the
launch-time race retry walk this sequence."""
yield base
for suffix in range(2, MAX_CONTAINER_SUFFIX + 1):
yield f"{base}-{suffix}"
def runsc_available() -> bool: def runsc_available() -> bool:
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime """Return True if the Docker daemon has the gVisor (`runsc`) runtime
registered. Called once per prepare; the result lives on the plan.""" registered. Called once per prepare; the result lives on the plan."""