Booting the app inside a session
An agent session that changes a screen should be able to look at the screen. For a long time it could not, and the reason was not a missing skill or a missing instruction — the host it runs on had no database on it anywhere the session could reach.
bin/agent-dev is the boot path that works from inside a session. This page is what it
does, what has to exist for it to work, and why the obvious alternatives don’t.
Where a session actually runs
Section titled “Where a session actually runs”A session is not its own container. AgentSessionJob runs inside the Kamal worker
container and spawns the agent CLI as a child process there, in a clone under
/home/rails/.zimmer/clones/. So the session inherits that container’s constraints:
| User | uid 1000 (rails), no root, no sudo |
| Docker | /usr/bin/docker exists, and /var/run/docker.sock is mounted — but usable only if the host grants the worker the socket’s group |
| Postgres client | none — no psql, no pg_isready, no server binaries. Only libpq and the pg gem |
| Network | the Kamal Docker bridge. Sibling accessories resolve by name (zimmer-redis, zimmer-devdb) |
Two consequences follow, and they are the whole story:
- A session cannot reliably start a database for itself. Not with a package manager (no root), not from the image (no Postgres binaries), and not with Docker on a host that has not granted the worker the socket’s group. The database has to already be running and reachable. A shared accessory is also simply the better answer than one Postgres per clone.
bin/devcannot work there. It assumes Postgres onlocalhost, Redis onlocalhost, andforeman— which is in the:developmentgem group the deployed image does not install.
What bin/agent-dev does
Section titled “What bin/agent-dev does”bin/agent-dev # pick a free port in 3000..3099, bootPORT=4000 bin/agent-dev # explicit portbin/agent-dev --skip-server # prepare the bundle + databases, don't bootIn order:
- Repairs the bundle.
bundle check || bundle install. - Points at the dev Postgres —
zimmer-devdbby default, overridable withZIMMER_DEV_DB_HOST/_PORT/_USERNAME/_PASSWORD/_SSLMODE. - Derives a per-clone database name from the clone directory and exports it as
DATABASE_NAME. - Preflights the connection with a TCP probe, so “no Postgres on this host” is one line rather than a Rails backtrace.
db:prepare, then a one-shottailwindcss:build(no watcher, no foreman).- Starts
bin/rails serveron a free port, binding0.0.0.0.
Confirm it with the health endpoint rather than a “server started” line:
curl -sf http://localhost:$PORT/up && echo UPThe three things that used to break it
Section titled “The three things that used to break it”There was no Postgres on the host at all
Section titled “There was no Postgres on the host at all”Production’s database is the off-droplet DigitalOcean Managed cluster; nothing on the
droplet’s Docker bridge listened on 5432. The fix is a Kamal accessory, devdb, declared
in both config/deploy.production.yml and config/deploy.staging.yml: a postgres:16
with clear credentials (zimmerdev/zimmerdev), reachable only on the private bridge,
holding nothing but scratch zimmer_dev_<clone> databases.
It is deliberately volume-less. Sessions come and go, each leaving a pair of databases behind; a durable volume would accumulate them forever, and a restart that should clean the slate wouldn’t.
It is deliberately not staging’s db accessory. That one holds staging’s own data on
a durable volume, and a session running a feature branch’s migrations has no business in
it.
Every clone shares one database server
Section titled “Every clone shares one database server”config/database.yml reads DATABASE_NAME in development and test, defaulting to the
historical literals when it is unset (every laptop, and CI, are unaffected). Set, it moves
all four names — <name>, <name>_cable, <name>_test, <name>_test_cable.
The test pair matters: db:prepare in development creates the test databases too, so
namespacing only the development pair would leave every session colliding on
zimmer_test. Postgres truncates identifiers past 63 bytes silently, so bin/agent-dev
caps what it sets at 52.
DATABASE_SSLMODE leaked into the session
Section titled “DATABASE_SSLMODE leaked into the session”CliSpawnEnv strips DATABASE_* and BUNDLE_* from the spawned agent’s environment so a
session resolves its own configuration instead of Zimmer’s. DATABASE_SSLMODE was missing
from that list. Production sets it to require; every local Postgres ships with
ssl = off; libpq’s answer to that pairing is a refused connection that reads like a
broken database rather than a leaked variable. It is cleared now, which is what leaves
bin/agent-dev free to export disable in its own shell. (Separately, a clone that sets
any of these in its own .env still wins over the clearing — that is by design, and not
the mechanism the script relies on.)
What it does not give you
Section titled “What it does not give you”- No worker process. GoodJob runs
:asyncinside the Rails process in development. - No CSS watcher.
tailwindcss:buildruns once. Re-run it after changing styles. - Redis is the deployment’s.
REDIS_URLis inherited and points at the realzimmer-redis; development’s cache store appends/1while the deployment uses/0, so they land in different logical databases. - A booted dev app holds real credentials.
RAILS_MASTER_KEYand the session’s environment are present. It is not a sandbox. It binds127.0.0.1by default for that reason;BINDING=0.0.0.0would offer it to every other container on the Kamal bridge.
Taking screenshots
Section titled “Taking screenshots”Playwright’s browsers are in the image. Drive the running server directly with
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright:
const { chromium } = require('/usr/lib/node_modules/playwright-stealth-mcp-server/node_modules/playwright-core');const b = await chromium.launch();const p = await b.newPage({ viewport: { width: 1280, height: 900 } });await p.goto(`http://localhost:${PORT}/`, { waitUntil: 'networkidle' });await p.screenshot({ path: '/tmp/shot.png' });await b.close();The test/e2e/ scripts are plain Node scripts against BASE_URL and are not run by CI.