feat(bottle): random-suffix identity + cli.py resume <identity>
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m30s

Replaces the cwd-hash identity with a random 5-char base36 suffix
per launch, so two simultaneous `start <agent>` invocations against
the same cwd no longer collide on container names. Each launch is
its own bottle.

State carries metadata: every prepare step writes
~/.claude-bottle/state/<identity>/metadata.json with the
(agent_name, cwd, copy_cwd, started_at) the bottle was launched
with. The new `cli.py resume <identity>` reads this metadata and
re-launches a bottle pinned to the same identity — picking up the
per-bottle Dockerfile (from a prior capability-block apply) and
the transcript snapshot under the same state dir.

- bottle_state.py: bottle_identity(agent_name) drops the cwd param
  and gains a random suffix; BottleMetadata dataclass +
  read/write/metadata_path helpers.
- BottleSpec gains an optional identity field — resume sets it to
  pin the identity; start leaves it empty so prepare mints fresh.
- prepare.py: writes metadata at launch time; uses spec.identity if
  provided (resume) else bottle_identity(agent_name) (fresh start).
- start.py: extracted _launch_bottle from cmd_start so resume can
  share the launch core; prints `./cli.py resume <identity>` hint
  at session end.
- cli/resume.py (new): reads metadata, reconstructs BottleSpec
  with the recorded identity + cwd, delegates to _launch_bottle.
  Errors clearly when no state exists for the given identity.
- cli/__init__.py: registers `resume` in COMMANDS + usage.
- dashboard.py: capability-block approval status line now appends
  the `resume <identity>` hint so the operator can copy-paste the
  rebuild command without leaving the TUI.

Closes the rebuild loop in PRD 0016: agent calls capability-block →
operator approves → bottle torn down with state preserved → status
line shows resume command → operator runs it → replacement bottle
boots with the new Dockerfile and prior transcript.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:09:45 -04:00
parent e996f72532
commit 4032e04a9c
8 changed files with 311 additions and 76 deletions
+19 -10
View File
@@ -11,6 +11,7 @@ via the base class's `prepare` template before this is called.
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
from ... import pipelock
@@ -27,10 +28,12 @@ from .cred_proxy import (
)
from .git_gate import DockerGitGate, git_gate_container_name
from .bottle_state import (
BottleMetadata,
bottle_identity,
per_bottle_dockerfile,
per_bottle_dockerfile_path,
per_bottle_image_tag,
write_metadata,
)
from .pipelock import DockerPipelockProxy, pipelock_container_name
from .supervise import DockerSupervise, supervise_container_name
@@ -54,16 +57,22 @@ def resolve_plan(
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
# PRD 0016 follow-up: identity, not bare slug. With --cwd, the
# identity carries a sha256(cwd) suffix so the same agent against
# different projects gets distinct container names, networks,
# queue + audit + state dirs. Without --cwd, identity ==
# slugify(agent_name) — same value the old `slug` produced — so
# no-cwd bottles look unchanged. We keep the variable named `slug`
# because every downstream module already threads it under that
# name; the value is now the bottle's full identity.
cwd_for_identity = Path(spec.user_cwd).resolve() if spec.copy_cwd else None
slug = bottle_identity(spec.agent_name, cwd_for_identity)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same
# agent in the same cwd don't collide on container/network
# names); a `resume` passes the recorded identity in via
# spec.identity to continue an existing bottle's state.
slug = spec.identity or bottle_identity(spec.agent_name)
# Record the launch metadata so `cli.py resume <identity>` can
# reconstruct the spec. Idempotent — re-writes on resume with a
# refreshed started_at.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
))
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
# written (via apply_capability_change), the base image becomes