polish: announce every wait and auto-recover a misconfigured registry

Two related papercuts: the user could neither tell when a long-running
step had started, nor recover from a registry container created (in some
past session) without `-p 127.0.0.1:5000:5000`.

build.sh
- Rewrite ensure_registry as a real state machine. Running-but-
  unresponsive triggers a remove-and-recreate after dumping diagnostics;
  stopped/etc start, then recreate if start didn't help. The "missing"
  path stays the same. Confirmed against a deliberately mis-bound
  container (registry on 5000/tcp only, no host port): script detects,
  recreates, push succeeds — no manual `docker rm -f` needed.
- Single poll_registry helper so the cheap-once-then-poll pattern lives
  in one place, with an explicit "==> Waiting for registry on ..." banner
  the first time we don't get an immediate response.

setup.rs (verify path)
- Print before each phase: "Booting throwaway sandbox", "Running
  claude/opencode/codex --version inside the sandbox", "Stopping verify
  sandbox". The verify step used to disappear for ~3s after the
  "Verifying" banner with no indication of progress.

run.rs (launcher)
- Print "Booting sandbox from <image> (<mem> MiB, <cpus> vCPU; first run
  pulls layers, otherwise ~3s)" before create(), "Attaching to <cmd>" or
  "Running <cmd> in sandbox (no TTY; output appears after exit)" before
  the attach/exec branch, and "Stopping sandbox" before stop_and_wait().
This commit is contained in:
Evgeny Boger 2026-05-17 18:20:58 +03:00
parent bb3e7d0a18
commit a57ed6d6ac
3 changed files with 86 additions and 42 deletions

View file

@ -105,6 +105,9 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
"/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/bin",
);
eprintln!(
"==> Booting sandbox from {image} ({memory_mib} MiB, {cpus} vCPU; first run pulls layers, otherwise ~3s)"
);
let sandbox = builder
.create()
.await
@ -113,6 +116,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
let cmd = agent.command();
let agent_args = args.agent_args;
let exit = if std::io::stdin().is_terminal() {
eprintln!("==> Attaching to {cmd} (Ctrl-P Ctrl-Q to detach)");
sandbox
.attach(cmd, agent_args)
.await
@ -121,6 +125,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// No host TTY (piped, redirected, smoke-tested under `sg`/`sudo` etc.).
// attach() needs a real /dev/tty for raw-mode stdin, so fall back to
// collected exec. Output is forwarded once the command exits.
eprintln!("==> Running {cmd} in sandbox (no TTY; output appears after exit)");
use tokio::io::AsyncWriteExt as _;
let output = sandbox
.exec_with(cmd, |e| e.args(agent_args).cwd("/workspace"))
@ -135,6 +140,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
output.status().code
};
eprintln!("==> Stopping sandbox");
sandbox.stop_and_wait().await.ok();
Sandbox::remove(&session.sandbox_name).await.ok();

View file

@ -66,6 +66,7 @@ fn build_script_path() -> Result<PathBuf> {
async fn verify_image(image: &str) -> Result<()> {
println!("==> Verifying {image}");
println!("==> Booting throwaway sandbox (this is the first VM cold-start; ~3s on a warm host)");
let sandbox = Sandbox::builder("agent-vm-setup-verify")
.image(image)
.registry(|r| r.insecure())
@ -76,6 +77,7 @@ async fn verify_image(image: &str) -> Result<()> {
.await
.context("booting verify sandbox")?;
println!("==> Running claude/opencode/codex --version inside the sandbox");
let out = sandbox
.shell("claude --version && opencode --version && codex --version")
.await
@ -83,6 +85,7 @@ async fn verify_image(image: &str) -> Result<()> {
println!("{}", out.stdout()?.trim_end());
println!("==> Stopping verify sandbox");
sandbox.stop_and_wait().await.ok();
Sandbox::remove("agent-vm-setup-verify").await.ok();

View file

@ -4,7 +4,8 @@
# microsandbox pulls images from registries by reference, so we run a tiny
# registry:2 container bound to 127.0.0.1:5000 and treat it as our local
# image store. The registry is shared across `agent-vm setup` runs; we
# create it on demand and never tear it down.
# create it on demand and recover by hand if a prior session left it in a
# bad state (running but no port published, crashed inside, etc.).
set -euo pipefail
@ -14,46 +15,19 @@ IMAGE_TAG="${AGENT_VM_IMAGE_TAG:-localhost:${REGISTRY_PORT}/agent-vm:latest}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ensure_registry() {
local state
state=$(docker inspect --type container -f '{{.State.Status}}' "${REGISTRY_NAME}" 2>/dev/null || echo missing)
case "${state}" in
running)
;;
missing)
echo "==> Creating local registry ${REGISTRY_NAME} on 127.0.0.1:${REGISTRY_PORT}"
docker run -d \
--name "${REGISTRY_NAME}" \
--restart=always \
-p "127.0.0.1:${REGISTRY_PORT}:5000" \
registry:2 >/dev/null
;;
*)
# exited, created, paused, restarting, dead — all need a kick.
echo "==> Starting existing registry container ${REGISTRY_NAME} (was: ${state})"
docker start "${REGISTRY_NAME}" >/dev/null
;;
esac
wait_for_registry
}
# `docker start` returns when the container has launched, but registry:2's
# HTTP listener takes another ~100ms to bind. A push fired immediately after
# loses that race with ECONNREFUSED, so poll until the v2 API answers.
#
# On failure, dump the container's port bindings and recent logs so the
# user can tell whether the registry process crashed, was bound to the
# wrong port, or never started — instead of just a bare timeout.
wait_for_registry() {
local i
for i in $(seq 1 50); do
if curl -fsS "http://127.0.0.1:${REGISTRY_PORT}/v2/" >/dev/null 2>&1; then
return 0
fi
# Returns 0 if /v2/ on the registry port answers within the timeout.
# Quiet — caller decides whether to log.
poll_registry() {
local attempts="${1:-50}" i
for ((i = 0; i < attempts; i++)); do
curl -fsS "http://127.0.0.1:${REGISTRY_PORT}/v2/" >/dev/null 2>&1 && return 0
sleep 0.2
done
return 1
}
dump_registry_diagnostics() {
{
echo "Registry did not become reachable on 127.0.0.1:${REGISTRY_PORT} after 10s."
echo "Container state:"
docker ps -a --filter "name=${REGISTRY_NAME}" \
--format ' status={{.Status}} ports={{.Ports}}' 2>/dev/null || true
@ -62,11 +36,72 @@ wait_for_registry() {
--format ' {{json .NetworkSettings.Ports}}' 2>/dev/null || true
echo "Last 30 lines of container logs:"
docker logs --tail 30 "${REGISTRY_NAME}" 2>&1 | sed 's/^/ /' || true
echo
echo "If the port mapping is wrong (a stale container from a prior session),"
echo "delete it and rerun setup:"
echo " docker rm -f ${REGISTRY_NAME} && agent-vm setup"
} >&2
}
create_registry() {
echo "==> Creating local registry ${REGISTRY_NAME} on 127.0.0.1:${REGISTRY_PORT}"
docker run -d \
--name "${REGISTRY_NAME}" \
--restart=always \
-p "127.0.0.1:${REGISTRY_PORT}:5000" \
registry:2 >/dev/null
}
recreate_registry() {
echo "==> Removing stale ${REGISTRY_NAME} container"
dump_registry_diagnostics
docker rm -f "${REGISTRY_NAME}" >/dev/null 2>&1 || true
create_registry
}
# Idempotent: bring the registry container into a "running and answering on
# 127.0.0.1:${REGISTRY_PORT}" state, no matter how it was left.
#
# Cases:
# - missing → create.
# - running, healthy → nothing.
# - running, unhealthy → recreate (was probably started in a past session
# without the right `-p` mapping, or the registry process crashed).
# - stopped/etc → start; recreate if still unhealthy after start.
ensure_registry() {
local state
state=$(docker inspect --type container -f '{{.State.Status}}' "${REGISTRY_NAME}" 2>/dev/null || echo missing)
case "${state}" in
running)
if poll_registry 5; then
return 0
fi
echo "==> ${REGISTRY_NAME} is running but 127.0.0.1:${REGISTRY_PORT} is unresponsive"
recreate_registry
;;
missing)
create_registry
;;
*)
echo "==> Starting existing registry container ${REGISTRY_NAME} (was: ${state})"
docker start "${REGISTRY_NAME}" >/dev/null
# registry:2 takes ~100ms after start to bind 5000 — short poll
# is normal. If the container was misconfigured (no port mapping)
# this longer poll will time out, and we recreate from scratch.
if poll_registry 25; then
return 0
fi
echo "==> ${REGISTRY_NAME} did not become reachable after restart"
recreate_registry
;;
esac
echo "==> Waiting for registry on 127.0.0.1:${REGISTRY_PORT} to accept connections"
if poll_registry 50; then
return 0
fi
{
echo "Registry did not become reachable on 127.0.0.1:${REGISTRY_PORT} after 10s."
echo "This is past our auto-recovery — Docker itself is probably misbehaving."
} >&2
dump_registry_diagnostics
return 1
}