Issue #49 removed game assets from git. It also, silently, removed the only transport agents had for showing each other evidence: each agent works in its OWN clone, so "commit the screenshot, the other one pulls it" was the mechanism, and PROTOCOL.md's file table said so in as many words -- "evidence cited by a finding -> git". That row sat directly above "🔴 Never commit game content", which is how 76 MB accumulated: the two contradicted each other and the table won, because it was the one that told you what to do. WHAT REPLACES IT * one host directory, `Sylpheed/docs/re/captures/`, bind-mounted read-write into BOTH agents at /work/docs/re/captures. All three -- host, decoder, port -- see the same files live, every citation resolves, and nothing can reach git history. Read-write on purpose: showing each other a screenshot is the point. * PROTOCOL.md's table rewritten. Cited evidence -> present but never committed; derived measurements (csv/tsv/txt/log/json) -> still git, they are our numbers not game content; evidence that must cross MACHINES -> attached to the issue or PR, because a bare clone has no captures. Verified, not assumed: container A wrote a .png there, a SEPARATE container B read it back, the host saw it, `git status` reported 0 changes, and `git check-ignore` named the rule. THE CHECKER WAS RED ON EVERY CLEAN CHECKOUT A fresh clone/worktree/CI has no captures, so it called all 134 citations dangling and exited 1. A gate that is red before anyone changes anything is one people learn to ignore -- the exact failure this file already carries a comment about. It now distinguishes "no captures here" (expected, explains itself, exit 0) from "these are missing" (real, exit 1, unchanged when assets ARE present). Both paths tested. ALSO * `sylph-decoder` no longer mounts `xenia-rs` -- retired repo, gone from disk, the mount pointed at nothing. * CONSOLIDATION.md closed: it still described captures as committed and the history fork as undecided. Both are settled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
14 KiB
Bash
Executable File
288 lines
14 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_GITEA_TOKEN this agent's own Gitea token file
|
|
# (default: $HOME/.sylph-gitea-token-port)
|
|
# 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"
|
|
# 🔴 EVIDENCE IS SHARED, NOT COMMITTED (issue #49). Each agent works in its
|
|
# OWN clone, so a gitignored capture written in one container is invisible to
|
|
# the other and to the human -- git used to be the transport and no longer is.
|
|
# One host directory, bind-mounted into both agents, is: all three see the
|
|
# same files live, every `docs/re/captures/...` citation resolves everywhere,
|
|
# and nothing can reach git history. Read-write on purpose -- showing each
|
|
# other a screenshot is the point.
|
|
-v "${SYLPH_CAPTURES:-$WORKSPACE/Sylpheed/docs/re/captures}:/work/docs/re/captures"
|
|
-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.
|
|
# Pass through: set SYLPH_OWN_LOGIN=1 when this container has run
|
|
# `claude auth login` itself, so the entrypoint never copies the host's
|
|
# rotating credentials over its own grant. Remote Control needs a real
|
|
# login -- the long-lived token does not carry the sessions scope.
|
|
[ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN")
|
|
|
|
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
|
|
|
|
# ── Gitea ──
|
|
# This agent's OWN token, for its OWN Gitea account — not the push credential
|
|
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
|
|
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
|
|
# request the agent authored is one a human can approve, which is the entire
|
|
# review gate; and revoking one agent then touches neither the other nor you.
|
|
#
|
|
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
|
|
# for why the value must not go through the environment.
|
|
# Inert until the file exists: the container still runs, with no issues.
|
|
local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}"
|
|
if [ -f "$giteatok" ]; then
|
|
_out+=(
|
|
-v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro"
|
|
-e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token"
|
|
)
|
|
else
|
|
echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2
|
|
echo " notifications, open an issue or open a pull request. Generate one" >&2
|
|
echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2
|
|
echo " write:repository, write:issue, write:notification, read:user." >&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
|