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.
249 lines
11 KiB
Bash
Executable File
249 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Launcher for the Godot port agent.
|
|
#
|
|
# ./sylph-port build build the image
|
|
# ./sylph-port shell interactive shell
|
|
# ./sylph-port loose [task] detached, self-running on a fixed interval
|
|
# ./sylph-port logs -f follow it
|
|
# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running)
|
|
# ./sylph-port remote a link to chat with it from anywhere
|
|
# ./sylph-port stop stop it
|
|
#
|
|
# Env:
|
|
# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4)
|
|
# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent)
|
|
# SYLPH_DISC extracted disc root
|
|
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
|
|
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
|
|
#
|
|
# ── Two hard-won constraints ────────────────────────────────────────────────
|
|
#
|
|
# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent
|
|
# or a human is working in. Sharing a working tree between two writers means
|
|
# files change under whoever is mid-edit, and a `git add -A` by one sweeps up
|
|
# the other's work. That happened; do not re-create it.
|
|
#
|
|
# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into
|
|
# a repo's config captures every commit made in that tree, including a
|
|
# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and
|
|
# nobody else's.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# The repo to mount at /work. Overridable so this script can be run from a
|
|
# worktree -- a human editing on `main` must not repoint the agent's checkout.
|
|
# The checkout is now a volume the container clones into, so this is only used
|
|
# to locate things that live BESIDE the repository -- the disc, chiefly. Three
|
|
# levels up from docker/port/ is the workspace root.
|
|
WORKSPACE="$(cd "$HERE/../../.." && pwd)"
|
|
IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}"
|
|
NAME="${SYLPH_PORT_NAME:-sylpheed-port}"
|
|
|
|
# Half of what the RE container takes. That container builds a C++ emulator and
|
|
# drives it; this one converts assets and runs Godot. Two full-size containers
|
|
# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding
|
|
# constraint, and an over-committed build has crashed this machine before.
|
|
CPUS="${SYLPH_PORT_CPUS:-3}"
|
|
MEM_GB="${SYLPH_PORT_MEM_GB:-4}"
|
|
|
|
DISC="${SYLPH_DISC:-$(cd "$WORKSPACE/sylph_extract" 2>/dev/null && pwd || true)}"
|
|
|
|
docker_args() {
|
|
local _out=(
|
|
--name "$NAME"
|
|
--hostname sylph-port
|
|
--cpus "$CPUS"
|
|
--memory "${MEM_GB}g"
|
|
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
|
|
# thrashes the whole host
|
|
--pids-limit 2048
|
|
-v "sylpheed-port-repo:/work"
|
|
-v "sylpheed-port-target:/sylph-home/port/target-container"
|
|
# CARGO_HOME on a volume, not the container overlay: without it the pinned
|
|
# decoder source is re-fetched from the network on every fresh container.
|
|
-v "sylpheed-port-cargo:/sylph-home/port/.cargo"
|
|
-v "sylpheed-port-claude:/sylph-home/port/.claude"
|
|
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude.seed:ro"
|
|
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
|
|
-v "sylpheed-exchange:/exchange"
|
|
-e "PROJECT_DIR=/work"
|
|
# Same guardrail as the decoder, added the same day and for its reason: the
|
|
# decoder was OOM-killed mid-task by a RAW `cargo test --release`, which
|
|
# never reaches `build-export`/`build-reference-cli` and so never saw their
|
|
# CARGO_BUILD_JOBS. This container is smaller (4 GB, 3 CPUs), so the same
|
|
# bypass is at least as easy to hit here.
|
|
-e "CARGO_BUILD_JOBS=${SYLPH_PORT_JOBS:-2}"
|
|
-e "SYLPH_EXCHANGE=/exchange"
|
|
-e "SYLPH_AGENT=port"
|
|
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
|
|
)
|
|
|
|
|
|
if [ -n "$DISC" ] && [ -d "$DISC" ]; then
|
|
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
|
else
|
|
echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2
|
|
echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2
|
|
fi
|
|
|
|
# Commits are attributed to the port agent, via the environment so that
|
|
# nothing is written into the repository's config. See constraint 2 above.
|
|
_out+=(
|
|
-e "GIT_AUTHOR_NAME=Sylpheed port agent"
|
|
-e "GIT_AUTHOR_EMAIL=port-agent@localhost"
|
|
-e "GIT_COMMITTER_NAME=Sylpheed port agent"
|
|
-e "GIT_COMMITTER_EMAIL=port-agent@localhost"
|
|
)
|
|
|
|
# Mounted as `.host` and copied to a writable file by the entrypoint, exactly
|
|
# like .claude.json. `credential.helper=store` REWRITES its file after a
|
|
# successful auth -- it writes a temp file and renames over the target, and
|
|
# renaming onto a bind-mount point gives EBUSY, which surfaces as
|
|
# `fatal: unable to write credential store: Device or resource busy`.
|
|
#
|
|
# The push still succeeds, which is the actual danger: a `fatal:` line that is
|
|
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
|
# rw would also silence it, but then the container can clobber the host's
|
|
# credential file; copying cannot.
|
|
# ── Claude auth ──
|
|
# See the decoder's launcher for the full note. Short version: the OAuth
|
|
# credential file holds a refresh token that ROTATES ON USE, three clients were
|
|
# seeded from one copy, and the loser of a rotation race has its tokens CLEARED
|
|
# to empty strings by Claude Code and parks at "Login expired". A long-lived
|
|
# `claude setup-token` credential passed in the environment has nothing to
|
|
# rotate and no file to empty. Same subscription, not API billing.
|
|
# Inert until the file exists.
|
|
local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
|
if [ -f "$claudetok" ]; then
|
|
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")
|
|
echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2
|
|
fi
|
|
|
|
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
|
if [ -f "$gitcred" ]; then
|
|
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
|
else
|
|
echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2
|
|
echo " so its work dies with the container." >&2
|
|
fi
|
|
|
|
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
|
|
|
|
# ── GPU ──
|
|
# This block did not exist until 2026-09-01, and its absence was reported as
|
|
# a symptom rather than a cause: **the port agent reported low FPS.** Godot 4
|
|
# renders through Vulkan, and with nothing passed through it was falling back
|
|
# to lavapipe — software Vulkan, which is correct and slow. The decoder's
|
|
# launcher had this logic; this one never did, so the container that actually
|
|
# runs a renderer was the one without a GPU.
|
|
#
|
|
# Three distinct cases, and conflating them is how you end up believing you
|
|
# have hardware Vulkan while running llvmpipe:
|
|
#
|
|
# NVIDIA needs the NVIDIA Container Toolkit (`--gpus all`). Passing
|
|
# /dev/dri alone does NOT work — Mesa cannot drive an NVIDIA card,
|
|
# and the proprietary userspace lives outside the image.
|
|
# Mesa (AMD/Intel) works with a plain /dev/dri passthrough plus the
|
|
# host's render/video GIDs.
|
|
# neither software Vulkan (lavapipe): correct, and slow.
|
|
if [ "${SYLPH_VULKAN:-auto}" = "sw" ]; then
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
|
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q nvidia; then
|
|
_out+=(--gpus all)
|
|
else
|
|
echo "==> NOTE: NVIDIA GPU found but the NVIDIA Container Toolkit is not" >&2
|
|
echo " installed, so Godot falls back to lavapipe (software — correct," >&2
|
|
echo " slow, and the reason for any low-FPS report). To enable it:" >&2
|
|
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \\" >&2
|
|
echo " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg" >&2
|
|
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \\" >&2
|
|
echo " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\" >&2
|
|
echo " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list" >&2
|
|
echo " sudo apt update && sudo apt install -y nvidia-container-toolkit" >&2
|
|
echo " sudo nvidia-ctk runtime configure --runtime=docker" >&2
|
|
echo " sudo systemctl restart docker" >&2
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
fi
|
|
elif [ -e /dev/dri/renderD128 ]; then
|
|
_out+=(--device /dev/dri)
|
|
for g in render video; do
|
|
gid=$(getent group "$g" | cut -d: -f3 || true)
|
|
[ -n "$gid" ] && _out+=(--group-add "$gid")
|
|
done
|
|
else
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
fi
|
|
|
|
printf '%s\n' "${_out[@]}"
|
|
}
|
|
|
|
mapfile -t ARGS < <(docker_args)
|
|
|
|
case "${1:-}" in
|
|
build)
|
|
exec docker build -t "$IMAGE" \
|
|
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE"
|
|
;;
|
|
|
|
shell)
|
|
TTY=(-i); [ -t 0 ] && TTY=(-it)
|
|
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash
|
|
;;
|
|
|
|
loose)
|
|
shift
|
|
TASK="${1:-}"
|
|
if [ -z "$TASK" ]; then
|
|
if [ -f "$HERE/../../docs/agents/port-loop.md" ]; then
|
|
TASK="$(cat "$HERE/../../docs/agents/port-loop.md")"
|
|
else
|
|
TASK="Work the milestones in docs/MISSION.md."
|
|
fi
|
|
fi
|
|
# A FIXED interval, not self-pacing: the one thing an agent deep in a
|
|
# milestone reliably forgets is the bookkeeping after it, and a forgotten
|
|
# wake-up silently ends the loop.
|
|
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
|
|
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
|
|
echo "==> repo: own clone in volume sylpheed-port-repo -> /work"
|
|
# `unless-stopped`, NOT `on-failure`: an OOM kill on this setup reports
|
|
# `OOMKilled: true` with **ExitCode 0**, so `on-failure` would read a memory
|
|
# kill as a clean finish and leave the agent down. Restarting into the same
|
|
# death is handled in the entrypoint, which refuses to `--continue` when the
|
|
# last start was under two minutes ago.
|
|
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
|
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
|
echo
|
|
echo " running detached as '$NAME'."
|
|
echo " ./sylph-port remote link to chat with it from anywhere"
|
|
echo " ./sylph-port logs -f follow it"
|
|
echo " ./sylph-port attach chat with it locally"
|
|
echo " ./sylph-port stop stop it"
|
|
;;
|
|
|
|
logs) shift; exec docker logs "$@" "$NAME" ;;
|
|
attach) exec docker attach "$NAME" ;;
|
|
stop) exec docker rm -f "$NAME" ;;
|
|
|
|
remote)
|
|
echo "waiting for the session to register" >&2
|
|
for _ in $(seq 60); do
|
|
# Read the container LOG, not the session transcript. The transcript
|
|
# records every command run inside the container -- including this
|
|
# lookup -- so grepping it matched our own pattern string back.
|
|
url=$(docker logs "$NAME" 2>&1 \
|
|
| grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \
|
|
| tail -1 || true)
|
|
[ -n "$url" ] && { echo "$url"; exit 0; }
|
|
sleep 2
|
|
done
|
|
echo "no session link yet — try ./sylph-port logs -f" >&2
|
|
exit 1
|
|
;;
|
|
|
|
*)
|
|
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
|
;;
|
|
esac
|