Assisted-by: Codex
15 KiB
PRD 0018: One Compose project per bottle instance
- Status: Draft
- Author: didericis
- Created: 2026-05-25
Summary
Replace the current pattern of orchestrating each sidecar with its own
docker SDK calls with one docker compose project per bottle
instance. The compose project is generated at start time, written
to disk under the instance's state dir, and brought up with
docker compose up. Tearing the instance down is docker compose down. Logs come from docker compose logs and land in a single file
per instance, so reading what happened in a session is one less
away.
State for each instance (~/.bot-bottle/state/<slug>/) becomes a
self-describing folder:
metadata.json # agent_name, cwd, started_at, compose project name, ...
docker-compose.yml # the exact compose spec used to start this instance
compose.log # full dump of `docker compose logs --no-color`
transcript/ # snapshotted agent conversation (existing)
live-config/ # routes.yaml, allowlist — bind-mounted into sidecars (existing)
Anything that needs to look at "what did instance X actually run?" can read those four artifacts. The compose file plus the metadata together fully describe the container topology.
Problem
Today start builds each sidecar (pipelock, egress, git-gate,
supervise) and the agent container with a chain of individual SDK
calls in bot_bottle/backend/docker/launch.py:
- A per-sidecar
Docker{Sidecar}.start()method doesdocker create→docker cp(stage files) →docker network connect→docker start. - Two networks are created up front (
network_createcalls). - The agent container starts last via its own
docker run.
This is fine, but it has three rough edges:
-
No single artifact describes the topology. To understand what ran for instance
<slug>, you have to read the Python that built the SDK calls. Nothing is on disk you cancat. -
Logs are scattered. Each container's logs sit in Docker's per- container journal. To debug a session post-mortem you have to remember to run
docker logs bot-bottle-pipelock-<slug>etc. before the containers age out, and there's no merged view. -
Teardown is bespoke. Each sidecar's
stop()is its own method, ordered carefully instart.py'sExitStack. A leftover container or network from a crash takes thecleanupCLI to find.
Compose is purpose-built for this shape: declarative spec, one project name per environment, merged logs, atomic up/down.
Goals / Success Criteria
bot-bottle start <agent>writes~/.bot-bottle/state/<slug>/docker-compose.ymland brings the project up withdocker compose -p <project> up.- The compose file is the source of truth for the container
topology — every sidecar that runs is declared as a
services:entry, every network is anetworks:entry, every bind mount is avolumes:entry. ~/.bot-bottle/state/<slug>/compose.logcontains the full merged stdout/stderr of every service for the session, indocker compose logs --no-colorformat.metadata.jsonrecords the compose project name alongside the existing fields (agent_name,cwd,started_at), so other tools can derivedocker compose -p <project> ...invocations without re-deriving the slug.- Session teardown is
docker compose -p <project> down. The existing per-sidecarstop()lifecycle methods come out. - The
cleanupCLI usesdocker compose ls(filtered tobot-bottle-*projects) instead of name-prefix scans acrossdocker ps -aanddocker network ls. - The existing remediation flows (
pipelock-block,egress-block,capability-block) keep working without protocol changes — they write to host paths understate/<slug>/live-config/, sidecarsSIGHUP-reload from the bind mount, no compose-side restart needed.
Non-goals
- Multi-host compose. No swarm, no remote contexts. Each instance is one local Docker daemon.
- Replacing the manifest format. Manifests stay; compose is an implementation detail of the Docker backend.
- Replacing the backend abstraction (PRD 0003).
Backendstays abstract; only the Docker implementation changes. - A long-lived "bot-bottle daemon." Each
startinvocation still owns a single compose project for the lifetime of the session. No persistent service. - Image pre-building. Compose's
build:directive triggers builds on firstup, same as today; no separate build step. - Backwards compatibility with running instances at upgrade. If an instance was started by the pre-compose code, the user kills it and starts a new one. There's no migration path for live containers.
Scope
In scope
- New module
bot_bottle/backend/docker/compose.pythat renders a compose dict from aBottlePlanand writes it tostate/<slug>/docker-compose.yml. DockerBackend.startrewritten to:- Build the plan (existing
prepare). - Stage bind-mount inputs (CAs, routes.yaml, env file, hooks)
into host paths under
state/<slug>/. - Render + write the compose file.
- Exec
docker compose -p <project> up -d. docker attach bot-bottle-<slug>for the agent's TTY.- On exit:
docker compose -p <project> logs --no-color→state/<slug>/compose.log, thendocker compose -p <project> down --volumes.
- Build the plan (existing
- Sidecar stage files move from
docker cp-into-container to bind-mounts fromstate/<slug>/. This deletes a lot of code inpipelock.py,git_gate.py,egress.py,supervise.py. metadata.jsongains acompose_projectfield.cleanupCLI rewritten to usedocker compose lsfor discovery.- The per-sidecar
Docker{Sidecar}.start/stoplifecycle methods collapse intoDocker{Sidecar}.compose_service()returning a service-dict fragment. Their apply / introspection helpers (egress_apply.py,supervise.py's handlers) are unchanged.
Out of scope
- Changing the manifest layer (
bot_bottle/manifest.py,egress.py's plan dataclasses,pipelock.py's plan dataclasses). - Changing the agent's runtime contract (proxy env vars, CA bundle paths, current-config mount path).
- Changing audit-log shape or location (
~/.bot-bottle/audit/<component>-<slug>.logstays). - Changing the MCP server's tool list or wire format.
- Dropping the
--rmsemantics for the agent: the agent container is still ephemeral; compose'sdown --volumeshandles cleanup.
Proposed design
Project name
compose_project = f"bot-bottle-{slug}". The slug stays the
existing slugify(agent_name)-<5-char-random-base36> from
bottle_state.py. Compose adds its own prefix to networks
(<project>_<network>) and to default container names — which is
why each service gets an explicit container_name: (below).
Service / container naming
Service names inside the compose file are short (agent,
pipelock, egress, git-gate, supervise). Each service sets
an explicit container_name: matching today's pattern:
services:
pipelock:
container_name: bot-bottle-pipelock-<slug>
egress:
container_name: bot-bottle-egress-<slug>
# ...
This keeps the dashboard's container-discovery output stable for
operators who've memorized the names. The compose project name
(bot-bottle-<slug>) is the only new identifier.
Networks
The two existing networks (bot-bottle-net-<slug> internal +
bot-bottle-egress-<slug> upstream-bridge) become compose
networks:
networks:
internal:
name: bot-bottle-net-<slug>
internal: true
egress:
name: bot-bottle-egress-<slug>
Each service's networks: list mirrors today's wiring.
Bind mounts replace docker cp
The current pattern of docker create → docker cp file container:/path → docker start (used by every sidecar to land
routes.yaml, CAs, hooks) becomes host bind-mounts. The host paths
live under state/<slug>/:
state/<slug>/
live-config/
routes.yaml
allowlist
pipelock-ca/
ca.pem
ca-key.pem
egress-ca/
ca.pem
ca-key.pem
git-gate/
entrypoint.sh
hooks/
...
env/
agent.env
Each sidecar service mounts the relevant sub-tree read-only at the
in-container path it expects. Permissions on the host paths are
locked to 0600/0700 at write time (existing mode=0o600 discipline
in prepare.py extends naturally).
Conditional services
The compose renderer takes the same BottlePlan the SDK calls
read today and only emits services for sidecars that apply:
pipelock— always.egress— only ifbottle.egress.routesis non-empty.git-gate— only ifbottle.gitis non-empty.supervise— only ifbottle.superviseis true.agent— always.
Conditional depends_on: edges keep the agent waiting on
sidecars that exist.
Logging
docker compose up -d starts everything detached. The agent is
attached for the user's TTY via docker attach bot-bottle- <slug>. Sidecars stream into Docker's per-container journals
during the session, exactly as today, and docker compose logs -f
gives a merged tail if the user wants it (the dashboard can shell
to this).
At session end (success or crash), start.py's ExitStack runs:
snapshot_transcript(slug)(unchanged).docker compose -p <project> logs --no-color --timestamps→state/<slug>/compose.log.docker compose -p <project> down --volumes.cleanup_state(slug)(unchanged — still removes the state dir unless.preservewas written).
The log dump is best-effort; a failure there shouldn't block teardown.
metadata.json shape
Add one field; everything else is unchanged.
{
"agent_name": "implementer",
"cwd": "/Users/.../some-project",
"started_at": "2026-05-25T20:13:04Z",
"compose_project": "bot-bottle-implementer-a7k3f"
}
Per-sidecar class shape
Today's DockerPipelock, DockerGitGate, DockerEgress,
DockerSupervise each carry start() + stop() lifecycle plus
helper logic (image building, route validation, apply handlers).
After this PRD:
- The
start()/stop()methods come out. - A new method per class,
compose_service(plan) -> dict, returns the service-stanza fragment (image / build / container_name / networks / volumes / env / depends_on). - The image-build flow becomes
build:in the compose file, so the per-sidecardocker buildcalls go away too. - The apply/introspection helpers (
egress_apply.add_route,supervise.py's capability handlers, etc.) are untouched — they read/write host paths understate/<slug>/live-config/and the bind-mounted sidecarsSIGHUP-reload.
Cleanup CLI
./cli.py cleanup switches from "list every container with prefix
bot-bottle- and every network with prefix bot-bottle-net-
or bot-bottle-egress-" to:
docker compose ls --all --format json→ filter to projects whose name starts withbot-bottle-.- For each:
docker compose -p <project> down --volumes. - Reap any state dirs under
~/.bot-bottle/state/whosecompose_projectno longer appears incompose ls.
Strays from pre-compose code-paths can be mopped up by keeping the existing prefix scan as a fallback for one release.
Open questions
-
docker composevsdocker-composev1. Compose v2 ships with Docker Desktop asdocker compose(subcommand) and is whattea pr createusers will already have. Assume v2; if v1 is detected, die with a pointer to upgrade. -
How does
claudereach the agent's TTY? Decided: keep today'sdocker exec -itmodel. Agent runssleep infinityunder compose;DockerBottle.exec_agentrunsdocker exec -it bot-bottle-<slug> claude ...exactly like today. Compose owns the lifecycle (socompose logsincludes the agent's stdout,compose downtears it down), but the user-facing exec model is unchanged. Rejecteddocker attachbecause its default Ctrl-P-Ctrl-Q detach intercept buffers keypresses Claude Code uses; rejected "agent outside compose" because it gives up the unifiedcompose logsview that motivated the PRD. -
TTY allocation under compose.Resolved by #2: notty:/stdin_open:on the agent service — interactivity is per-exec. -
docker compose logsordering. The dumped log file interleaves services by timestamp. Confirm--timestampsis enough to keep it readable; otherwise consider per-service subfiles (compose.log.pipelock, etc.). -
Image build caching.
build:in compose rebuilds on firstupunless the image is already tagged. The per-sidecar images (bot-bottle-pipelock,bot-bottle-egress,bot-bottle-git-gate,bot-bottle-supervise) should stay tagged on the daemon between runs so we don't rebuild on every start. Verify compose's behavior matches. -
docker compose down --volumesand bind-mount data.down --volumesremoves named volumes but leaves bind-mount source paths alone (they're host paths under our state dir, which we manage explicitly). Confirm — and if there's a footgun, drop--volumesand rely on the state-dir cleanup step. -
Dashboard discovery.
cli/dashboard.pyenumerates instances by scanning containers. Should it switch todocker compose lstoo, or readmetadata.jsonfiles understate/? Reading state dirs is faster and survives docker daemon restarts; compose ls is the truth about what's actually running. Probably both: list from state dirs, mark "running" by cross-referencing compose ls.
Implementation chunks
Sized for one PR each, in order.
- Compose renderer. Pure function:
bottle_plan_to_compose(plan) -> dict. No I/O. Full unit-test coverage for the conditional-service matrix (every combination of git on/off, egress on/off, supervise on/off). Nostart.pychanges yet. - Stage-file move to host paths. Refactor each sidecar's
stage-file production (today: write to host stage dir →
docker cpafter create) to write directly intostate/<slug>/sub-trees with bind-mount-ready perms. SDK path still doesdocker cp; this is a no-op rearrangement that sets up chunk 3. - Switch
start.pyto compose. Wire up the renderer +docker compose up -d+ attach + teardown. Per-sidecarstart()/stop()lifecycle methods deleted in the same chunk. Compose- log dump on teardown added. - Cleanup CLI on compose. Switch
./cli.py cleanuptodocker compose ls-based discovery; keep prefix-scan as fallback for one release. - Dashboard. Decide on the discovery question (open question #7), implement.
References
- PRD 0003 — bottle backend abstraction (what stays / what changes underneath it)
- PRD 0010 / 0017 — cred-proxy → egress; the sidecar lifecycle this PRD collapses into compose
- PRD 0014 / 0015 / 0016 — apply flows that bind-mount-+-SIGHUP has to keep working without protocol change