The rotating OAuth credential file is why the agents kept parking, and a
long-lived token removes the failure by construction instead of recovering from
it after the fact.
MEASURED 2026-09-04. ~/.claude/.credentials.json holds a refresh token that
ROTATES ON USE. Seeding both containers from the host left three clients holding
one token; the first to refresh invalidated the other two, and on the failed
refresh Claude Code CLEARS the stored tokens -- writes empty strings, keeps the
metadata, and parks at "Login expired".
decoder credentials emptied 13:04:28
decoder last transcript 13:04:29 <- one second later
The emptying and the park are the same event, which is why it never self-heals:
not a stale token a retry could fix, but no token at all, with no browser in the
container to complete /login. A hollow file passes every "does it exist" check --
508 B healthy against 280 B emptied -- which is how three separate diagnoses
missed it. And recovery re-armed the bug: after re-seeding, host and decoder held
the IDENTICAL refresh token hash.
`claude setup-token` issues a long-lived token against the same Claude
subscription. Checked, not assumed: `claude auth login` defaults to --claudeai
and it is `--console` that means Console/API billing, so this is not the separate
API bill. `CLAUDE_CODE_OAUTH_TOKEN` is recognised by the installed binary.
Passed as an ENVIRONMENT VARIABLE, both halves of the failure are gone: nothing
rotates, so peers cannot invalidate each other, and there is no file for Claude
Code to empty on a failure.
Both launchers read $HOME/.sylph-claude-token if present -- same pattern as
SYLPH_GIT_CREDENTIALS -- and both entrypoints skip OAuth seeding entirely when
the variable is set, because copying the rotating file in would re-create the
exact collision the token exists to remove.
Inert until the file exists. Without it, nothing changes.
Also worth recording for the preflight work: `claude auth status` prints JSON
with loggedIn/authMethod/subscriptionType. That is a far better SessionStart
assertion than checking a file exists, and it would have caught this on the first
iteration rather than the third incident.
A tooling review predicted a PID-1 signal problem from two symptoms we could not
explain: `OOMKilled: true` with **ExitCode 0**, and `--continue` failing to find
a conversation that plainly existed. Traced it, and the prediction was right --
though the culprit is not PID 1, it is one level below.
The path is tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
`spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
is the process Docker signals, and everything depends on it passing things on.
It did neither, in two lines:
1. NO SIGNAL FORWARDING, no trap of any kind. `docker stop` sent SIGTERM to
expect, which died and took the pty with it. Claude Code never got a SIGTERM,
so it never ran SessionEnd hooks and never wrote lastSessionId/history --
which are written ONLY at a graceful shutdown. That is the entire reason
`claude --continue` answered "No conversation found to continue" with 33 MB of
transcripts in the volume beside it, and why we resume by scraping a session
id off a transcript filename.
2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
ZERO. When the OOM-killer took the child, expect saw EOF and reported a clean
exit. `OOMKilled: true` with `ExitCode 0` was never Docker being odd -- it was
this line. It also meant `--restart on-failure` would read a memory kill as
success, which is why the policy had to be `unless-stopped`.
Fixed and MEASURED, old against new, in a container:
child exits 7 old -> 0 (the bug) new -> 7
SIGTERM to wrapper old -> 143, child's trap NEVER RAN
new -> 42, child trapped and cleaned up
Same file in both images; they were byte-identical, so the port copy takes the
same change.
Consequences worth stating: a kill now reports 137 rather than 0, so exit codes
mean what they say; `docker stop` gives Claude Code a real SIGTERM, so it runs
SessionEnd and writes the session index -- which may make the transcript-filename
resume unnecessary. That is not assumed here: the resume path stays as it is
until it is verified redundant.
The decoder died mid-task and it took four separate findings to explain, each
of which read as something else:
1. OOM-KILLED, REPORTED AS A CLEAN EXIT. `OOMKilled: true` with **ExitCode 0**.
So `--restart on-failure` would treat a memory kill as a successful finish
and leave the agent down -- the policy has to be `unless-stopped`.
2. THE JOB CAP WAS SET AND THEN REMOVED THREE LINES LATER. build-reborn has
always exported CARGO_BUILD_JOBS, but a raw `cargo test --release -p
sylpheed-formats` never reaches the wrapper. Adding `-e CARGO_BUILD_JOBS` to
the launcher did not help either: the entrypoint recomputes and exports over
it unconditionally. An explicit value now wins, and says so in the log.
3. THE MEMORY CONSTANT WAS WRONG. `mem_gib * 2 / 3` assumes ~1.5 GB per job;
release rustc on this workspace needs ~2 GB, and 4 jobs in 6 GB is what died.
Divisor is now 2.
4. `--continue` CANNOT RESUME AN ABRUPT DEATH, which is the only kind we get.
It resolves through ~/.claude.json's per-project `history`/`lastSessionId`,
and MEASURED mid-session both are None -- they are written at a graceful
shutdown. A killed container never writes them, so `--continue` answered
"No conversation found to continue" with 33 MB of transcripts in the volume
beside it. Persisting .claude.json did not help, because the fields were
never populated in the first place; that attempt is removed rather than left
in looking useful.
The TRANSCRIPTS are durable and named by session id, so the entrypoint reads
the id off the newest one for its cwd and passes `--resume <id>`. Verified
on both agents: each reattached to its exact prior session and appended to
the same file rather than opening a new one.
The /loop prompt is still passed alongside `--resume`, so the loop is RE-ARMED
rather than merely restored -- a resumed conversation with no wake-up scheduled
answers once and stops, which looks like resuming and is not.
Restarting into the same death is guarded at the other end: a start less than
120 s after the previous one begins FRESH instead of continuing back into
whatever killed it. That fired correctly during this work.
On resume the agent is told it was restarted, that its in-progress work is
uncommitted in the tree, that any build or capture it had running did not
finish and its absence is not a result, and which wrapper to prefer over a raw
release build.
Credentials were seeded only when the container's copy was MISSING. So when a
session expired, the file still existed, the copy was skipped, and restarting
changed nothing -- the one recovery path a human has, re-logging in on the host,
could not reach the containers at all.
Now re-seeds whenever the host's copy is newer. Newer-wins rather than
always-copy, because a container refreshes its own token mid-run and that copy
may legitimately be the fresher of the two.
Found when both sessions expired: host credentials at 16:30, containers holding
14:20 and 14:24.
The decoder had neither, and reported the gap precisely: four scripts in this
repo READ /work/xenia-rs/sylpheed.db and nothing produces it, so the whole static
PPC route was consumers with the producer missing.
Both exist on the host and are now mounted read-only: the 586 MB database (25 481
functions, 851 classes with RTTI, EH tables, imports, 1.8M indirect-dispatch
candidates) and the decompressed image.
The image is the more useful of the two. It is a FLAT VA DUMP -- file offset =
VA - 0x82000000 -- so reading a known address needs no XEX decrypt, no LZX, and
no booted emulator. The decoder had independently recovered the same bytes by
dumping /dev/shm/xenia_memory_* and validating against the GamePart table, which
is good work and a sound method, but it noted itself that needing a running
emulator is a bad dependency for something the entire static corpus rests on. It
does not need one.
Also recorded that an earlier claim the .pe was STALE was tested and refuted, so
nobody re-litigates it, and that instructions.raw is an INT rather than hex.
Written down as reference material, explicitly NOT a deliverable: they are
read-only, they come from outside the repository, and a fresh checkout elsewhere
has neither. Reimplementing the producer belongs in sylpheed-formats, and until
it exists every static finding rests on an artefact this project cannot rebuild.
The audio work was three parts and I shipped two. The transcode-fidelity method
and the pinned 5.1 downmix landed; the null sink -- the only one that answers
"what does the GAME play" -- I deferred to "the next natural rebuild window" and
then rebuilt both images four times without doing it.
pulseaudio-utils is now in both, with tools/audio-capture wrapping it: a null
sink is a real device as far as an application is concerned, so Canary and Godot
open it normally and parec records what they emit.
This unblocks the decoder's Q8. The cue-to-event bindings are currently a name
match against the authors' own identifiers -- a plausible guess, not a
measurement -- and capturing what the game plays on a menu move converts them.
`audio-capture run` reports the peak level and warns when the capture is silent,
because silence is the failure that looks like success: a WAV of exactly the
right duration, full of zeroes, because the application opened a different sink.
A duration check alone passes it, which is how a confident wrong number gets
made.
Three defects, all mine, found by checking instead of assuming.
**They never exchanged a word.** SendMessage=0, ListAgents=0 across both new
sessions. PROTOCOL.md specified in detail what a message MAY and MAY NOT do and
never said how to send one or that the other agent was addressable -- they knew
that last time only because the human told them directly, and rebuilding with
fresh volumes wiped it. Policy without mechanism is prose. Now documented with
the two addresses, a worked example, and an instruction to introduce themselves
on the first iteration rather than waiting to have a question.
**The decoder lost the disc and the ISO.** They used to arrive inside the project
mount and silently stopped when /work became a clone. Silently is the word: the
disc-gated tests SELF-SKIP without SYLPHEED_DISC and report green, so a whole
test suite would have passed while measuring nothing. Both are now mounted
explicitly, the ISO at a stable path so run-canary does not depend on host
directory names.
**Both agents shared one Claude state directory.** They share the host's
~/.claude, and once both working directories became /work they resolved to the
same projects/-work/ -- two supposedly independent agents writing to one place,
which undoes the point of separate checkouts. Each now has its own volume, seeded
once from the host with credentials only, so a token refresh writes locally and
neither can corrupt the host's auth.
Also widened the pacing rule. It banned ScheduleWakeup by name; the decoder then
scheduled itself an hourly cron job -- not harmful, but the same instinct that
ended a run yesterday, through a door I had left open. Now: no self-scheduling by
any route.
Mount audit after the changes: shared and intentional are the exchange volume and
the read-only credential seed. Everything else -- repo, Claude state, cargo,
target, canary, disc, ISO -- is per agent or one-sided.
Two bugs, both mine, both found by starting the thing.
**Volume mount points must exist AND be owned by the agent before USER agent.**
Docker seeds a named volume from whatever the image has at that path, ownership
included, and creates a ROOT-OWNED directory when the path is absent. Either way
the agent cannot write, and the failure surfaced far from its cause: "clone
FAILED", with no permission error anywhere in sight. The port's own Dockerfile
already carried a comment explaining this trap, which I then walked into for
/work and /exchange.
**The clone guard checked for a .git directory, not a usable HEAD.** A clone
interrupted partway -- the container was removed while one ran -- leaves a .git
with no commits, and a presence check then skips the retry forever and hands the
agent an empty repository that looks like a checkout. It now verifies HEAD, and
clones via a temp directory so a partial result never lands in /work at all.
Also: the port launcher's path defaults still assumed the old repo root, so it
mounted no disc; and the stale /reborn notice is gone now that there is one
repository.
Verified running: both agents cloned c58196b, `share` on PATH from /work/tools,
/exchange agent-owned, canary at /canary for the decoder, disc at /disc for the
port.
The last structural fix for the collision class that has bitten three times. Both
containers now clone the repository into their OWN named volume instead of
bind-mounting a human's working tree, so an agent's local git config cannot
capture a human's commits, a credential helper cannot leak a container-only path
onto the host, and a `git add -A` cannot sweep another party's in-flight files.
Cloned once at startup and never auto-pulled: pulling under a running agent
moves files out from under whatever it is mid-edit, which is the same bug again.
Accepted knowingly: Claude Code keys per-project memory off the working
directory, so moving off the host path starts that memory empty. The corpus in
docs/ is the memory that matters and it travels with the clone.
Other changes:
* docker/agent -> docker/decoder; the launcher is sylph-decoder. Roles, not
"the agent", now that there is more than one.
* /reborn is gone -- one repository now, so the port reads HANDOFF from its own
checkout rather than through a live read-only mount of someone else's tree.
* Canary mounts separately at /canary; it stays a fork tracking upstream.
* A shared `sylpheed-exchange` volume at /exchange, with tools/ on PATH so
`share` is available in both.
* The decoder's credential file gets the .host-copy treatment the port already
had -- `credential.helper=store` rewrites by rename-over-target, which is
EBUSY on a bind mount and reports a fatal that is not one.
* Budget split deliberately: decoder 5 cpu / 6 GB, port 3 / 4, leaving room for
the planned Referee. "Half the host" was right when there was one agent.
Prompts move to docs/agents/ and are rewritten around the protocol: the oracle
is the running game, dynamic RE stays with the decoder, each iteration must
attempt to refute one claim of the other, and neither may verify its way out of
its own role.