Written for the other machine, which asked. Everything in it was read off the running host rather than remembered. Also adds docker/ci/ -- the CI image recipe and a capped runner -- because the image existed on exactly one host and its Dockerfile was in a scratch directory under /tmp, which was swept. That is the same shape as every other thing this consolidation has turned up: something correct that exists in one place. docker/ci/Dockerfile rust 1.98.1 + the apt list copied from ci.yml docker/ci/Dockerfile.ffmpeg + ffmpeg, which sylpheed-export shells out to docker/ci/run 6 CPUs / 7 GB / NO SWAP, named cargo volumes The rule the runner exists to enforce: every heavy command goes in the capped container. CARGO_BUILD_JOBS caps codegen units, not rustc's threads, not the linker, not the test harness -- a bare host build is unbounded and has frozen this box repeatedly. The document also records the two agent defects the other machine will meet: a brief change does not reach a RESUMED session, and nothing brought an agent back to its own red PR (fixed in #24, which is itself subject to the first). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.2 KiB
Containers and agents — how this is actually set up
Written 2026-09-13 for the other machine. Everything below was read off the running host, not remembered. Paths are as they exist on the desktop; the Pi differs where it says so.
1. The rule that governs all of it
🔴 Every heavy command runs in a capped container. No exceptions, including one-off checks.
docker/ci/run cargo check --workspace --all-targets
A bare cargo build on the host is unbounded. CARGO_BUILD_JOBS caps
codegen units — not rustc's own threads, not the linker, not the test harness.
A full-parallel build has OOM-crashed this box, and unbounded host runs have
frozen it repeatedly since. --memory-swap equal to --memory means a build
that would swap is killed instead: a fast failure beats an hour of thrashing
that takes the desktop with it.
What may run on the host: git, grep, python3 over text, API calls. Anything
that compiles, links, transcodes or boots an emulator does not.
2. Three kinds of container
| image | what it is | |
|---|---|---|
| CI / build | sylph-ci:local |
the runner, reproduced locally — docker/ci/Dockerfile |
| Decoder agent | sylpheed-agent:latest |
an autonomous Claude Code session doing RE — docker/decoder/ |
| Port agent | sylpheed-port:latest |
the same, building the Godot port — docker/port/ |
2.1 The CI image
docker/ci/Dockerfile — rust:1.98.1-bookworm, the apt list copied verbatim
from .github/workflows/ci.yml, plus clippy, rustfmt and the
wasm32-unknown-unknown target.
docker build -t sylph-ci:local docker/ci
docker build -t sylph-ci:ffmpeg -f docker/ci/Dockerfile.ffmpeg docker/ci # exporter only
⚠️ The Rust version is pinned here and floats on the runner
(dtolnay/rust-toolchain@stable). That is issue #15, and it is not theoretical:
collapsible_else_if is warn on 1.92 and allow on 1.98.1. When a local
clippy pass and a runner pass disagree, the runner is the authority.
⚠️ cargo clippy stops at the first failing compilation unit. Without
--keep-going the list looks short and is not. On the corpus branch the honest
count was 80, and the first run showed 14.
docker/ci/run gives 6 CPUs / 7 GB / no swap, mounts the repo at /work, the
extracted disc read-only at /disc, and uses named volumes for CARGO_HOME
and CARGO_TARGET_DIR — never the host target/, which still holds 32 GB from
older host-side builds and produces rebuilds that look like cache misses.
2.2 The agent containers
Launched from the host by docker/decoder/sylph-decoder and
docker/port/sylph-port. Each starts a Claude Code session inside a container
with the repo, the disc, Canary's source and a warm build tree.
The budget is split deliberately, not halved per container — there are two agents and a Referee is planned, so each claiming half a box it shares would oversubscribe it:
| CPUs | memory | /dev/shm |
|
|---|---|---|---|
| decoder | 5 | 6 GB | 2 GB |
| port | 3 | 4 GB | — |
Overridable with SYLPH_CPUS / SYLPH_MEM_GB and SYLPH_PORT_CPUS /
SYLPH_PORT_MEM_GB. /dev/shm is an exec-capable tmpfs, not --shm-size:
Docker mounts the default noexec and Xenia maps its JIT code cache out of a
shm file. Its pages count against the memory cap, hence a third of it and no
more.
Volumes (docker volume ls):
sylpheed-decoder-repo sylpheed-port-repo the agent's own clone
sylpheed-decoder-claude sylpheed-port-claude Claude Code state + transcripts
sylph-agent-cargo sylpheed-port-cargo CARGO_HOME
sylph-agent-target sylpheed-port-target CARGO_TARGET_DIR
sylph-agent-canary-build the warm 235 MB Canary build tree
sylpheed-exchange agent -> agent files, read-only in
Credentials — three files on the host, chmod 600, mounted read-only:
~/.sylph-claude-token Claude Code OAuth (a subscription, never the API)
~/.sylph-git-credentials the agents' PUSH credential — write:repository ONLY
~/.sylph-gitea-token-decoder the decoder's own Gitea account, for issues/PRs
~/.sylph-gitea-token-port the port's
~/.sylph-gitea-token-fabi the HUMAN's, full grant — mounted into NOTHING
🔴 The narrow scope on ~/.sylph-git-credentials is load-bearing. Every
issue endpoint refuses it, which is what stops an agent reaching the Gitea API
as the human. Putting a broader token there would hand both agents the human's
identity, including merge. The human's own token lives in a path no launcher
reads and no container mounts — verified, not assumed.
The Gitea MCP server is passed the token by path, not by value:
GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token.
3. How an agent iteration actually works
Both briefs — decoder-loop.md, port-loop.md — are read from the host tree
at container launch. The loop is: read notifications → pick one approved issue
→ do the smallest experiment → classify → refute something → write it down →
push-work, open the PR, label state/needs-human, stop.
⚠️ Notifications are POLLED. Nothing is pushed. An @mention or a PR comment
reaches an agent only on its next iteration.
🔴 Two defects the other machine should know about
A brief change does not reach a running agent. The entrypoint resumes the
newest transcript in the container's Claude volume, and the only guard is a
<120 s restart-loop test. Everything older resumes unconditionally — so a
container restarted after a workflow change comes back still following the old
brief, on the old branch. Seen: a decoder resumed a 7-day-old pre-migration
session and had to be stopped. Until the entrypoint learns to test transcript
age (or take a SYLPH_FRESH=1), adopting a brief change means archiving the
transcripts in sylpheed-decoder-claude/projects/ and starting fresh.
Nothing brought an agent back to its own red PR. It opened one, labelled the issue, stopped — correctly, per the brief — and the next firing started a new question on top. It pushed over a red CI six times. Fixed in both briefs (#24), which is itself subject to the defect above: the fix only reaches a fresh session.
4. The other machine's half
The Pi runs the Gitea instance (proxied to git.mc02.dev via a VPS, reachable
from the LAN and the internet) and the Actions runner. Its
/var/lib/docker is still on the 117 GB SD card beside an idle 916 GB SSD;
moving data-root is outstanding.
⚠️ The runner serialises. Three jobs per push, one at a time — a PR's checks sat pending for 40+ minutes behind a failed native job. A pending check there is not necessarily a slow one.
5. Standing constraints that are not about containers
- One emulator process at a time, ours or Canary, never both — a lockfile.
- Canary runs muted. Point it at the real ISO, not the
sylpheed.isosymlink (Wine cannot resolve it). - Never judge a crash or a hang from a Bash-launched emulator run. A SIGKILL that looked like our binary was the editor's process supervisor. Ask the human to run it natively.
- Never screenshot the port or the emulator yourself; ask the human.
- The oracle is the real game in Xenia Canary — not our renderer, not the port.