# scrum4me-agent-runner Headless Claude Code worker die de Scrum4Me job-queue (M13) leegtrekt vanaf een QNAP NAS via Container Station. Geen Vercel, geen browser, geen toetsenbord — Claude Code draait als daemon, claimt jobs uit `mcp__scrum4me__wait_for_job`, voert ze uit in een per-job clone, en pusht nooit zelf. ## Architectuur in één plaatje ``` ┌─ QNAP TS-664 (Container Station) ─────────────────────────────┐ │ │ │ ┌─ container: agent-runner ────────────────────────────────┐ │ │ │ PID 1: tini → run-agent.sh (daemon-loop) │ │ │ │ ├─ health-server.js (8080 → host 18080) │ │ │ │ └─ claude -p (per-batch, met MCP via stdio) │ │ │ │ └─ scrum4me-mcp → Neon Postgres │ │ │ │ │ │ │ │ /tmp/job- ephemeral working trees │ │ │ │ /var/cache/repos bare git mirrors (volume) │ │ │ │ /var/cache/npm npm cache (volume) │ │ │ │ /var/log/agent run + job logs (volume) │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ /share/Agent/cache /share/Agent/logs /share/Agent/state │ └────────────────────────────────────────────────────────────────┘ │ ▼ HTTPS Neon Postgres (Scrum4Me DB) ▲ │ Vercel ─── Scrum4Me UI (gebruikers enqueueen jobs) ``` Eén `claude -p`-invocation roept intern `wait_for_job` aan totdat de queue leeg is (≈600 s lege block-time → afsluiten). De wrapper start `claude -p` opnieuw zodra hij eindigt, met exponentiële backoff bij fouten. ## Wat zit waar | Bestand | Doel | |--------------------------|-----------------------------------------------------------------| | `Dockerfile` | Ubuntu 22.04 + Node 22 + Claude Code + scrum4me-mcp + scripts | | `docker-compose.yml` | Service-definitie, volumes, env-file, restart-policy, limits | | `package.json` | Npm-dependencies van de runner zelf (alleen `scrum4me-mcp` pin) | | `mcp-config.json` | Claude Code MCP-config (verwijst stdio naar scrum4me-mcp) | | `CLAUDE.md` | Agent-rol-instructies, auto-geladen door `claude -p` | | `bin/entrypoint.sh` | Container-startup: dirs, health-server, daemon-loop | | `bin/run-agent.sh` | Daemon-loop met backoff, exit-code-routing en state-writes | | `bin/check-tokens.sh` | Pre-flight: API-token, OAuth-token, DB-bereikbaarheid | | `bin/job-prepare.sh` | Per-job: bare-fetch + clone-via-reference naar `/tmp/job-` | | `bin/job-cleanup.sh` | Per-job: logs naar `/var/log`, working tree weg | | `bin/health-server.js` | HTTP-endpoint op 8080 (intern) dat state.json en marker-files leest | | `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden | | `.env.example` | Alle env-vars met uitleg | ## Vereisten op de NAS - Container Station 2+ (Docker compose v2) - **`Agent` als QTS Shared Folder** op een echte volume (bv. `CACHEDEV1_DATA`). Niet een `mkdir /share/Agent` — `/share` zelf is een 16 MB tmpfs en handmatige directories overleven geen reboot. Aanmaken via Control Panel → Privilege → Shared Folders → Create. QTS legt dan automatisch de symlink `/share/Agent → /share/CACHEDEV1_DATA/Agent`. - Drie subdirs onder die share: `/share/Agent/cache`, `/share/Agent/logs`, `/share/Agent/state`. Aanmaken via File Station of via SSH na share-creatie. - Internet-uitgang naar `api.anthropic.com`, `github.com`, je Neon-host, `registry.npmjs.org`. > **Verifieer** vóór je deployt dat `/share/Agent` echt op disk staat: > ```bash > ssh admin@ 'ls -la /share/ | grep Agent; df -h /share/Agent' > ``` > Verwacht een symlink (`l...Agent -> /share/CACHEDEV1_DATA/Agent`) en een > df-uitvoer met TB-grootte op `cachedev1`/`cachedev2`. Als je hier `tmpfs 16M` > ziet, is de share geen geregistreerde QTS Shared Folder en zal elke transfer > >16 MB falen met `scp: write remote ... Failure`. ## Deploy ```bash # 1. Op je werkstation: token's regelen # a. CLAUDE_CODE_OAUTH_TOKEN → draai `claude setup-token` (browser-flow) # b. SCRUM4ME_TOKEN → log in als de dedicated agent-user in # Scrum4Me, /settings/tokens, label "NAS-runner" # c. DATABASE_URL/DIRECT_URL → Neon dashboard # d. GH_TOKEN → github.com → Settings → Developer settings → # Personal access tokens → Fine-grained. # Repository access op madhura68/Scrum4Me + # madhura68/scrum4me-mcp; Permissions: # Contents (RW), Pull requests (RW), # Metadata (R). Wordt gebruikt voor clone, # push en `gh pr create` (auto_pr). # 2. Repo op de NAS plaatsen ssh admin@nas cd /share/Agent git clone https://github.com//scrum4me-agent-runner.git cd scrum4me-agent-runner # 3. Env aanmaken cp .env.example .env chmod 600 .env vi .env # vul alle waarden in # 4. Build + start docker compose build docker compose up -d # 5. Verifiëren curl http://nas.local:18080/health docker compose logs -f ``` > **QNAP-port:** host-poort 8080 is bezet door de QTS-webinterface; daarom > mapt deze stack standaard `18080:8080`. Override via > `AGENT_HEALTH_PORT_HOST` in `.env` als je een andere host-poort wilt. ## Snelle redeploy — `bin/deploy-to-nas.sh` Voor een **bestaande deploy** die je opnieuw wil bouwen + deployen (bijvoorbeeld na een merge in `scrum4me-mcp` of een aanpassing aan `CLAUDE.md`): ```bash # Eenmalig: NAS-target instellen cp .env.deploy.example .env.deploy vi .env.deploy # zet NAS_HOST=admin@ # Daarna: één commando voor de hele cyclus bin/deploy-to-nas.sh ``` Het script doet: 1. `docker buildx build --platform linux/amd64 --load` 2. `docker save | gzip → scrum4me-agent-runner-amd64.tar.gz` 3. `scp` van tarball + `docker-compose.yml` + (eerste keer) `.env` naar NAS 4. `ssh` op NAS: `docker load` + sanity-check op `.env` + `docker compose up -d --force-recreate` 5. `docker compose logs -f` — lokaal-volgbaar terwijl pre-flight + eerste batch starten `.env` op de NAS wordt **niet** overschreven als 'ie er al staat. Bij een verse NAS-installatie wordt 'ie wél geüpload + ge-sed't (NAS_BASE, AGENT_UID, etc.). Voor de **eerste deploy** of een schoon volume zie de volledige procedure hieronder. ## Deploy — cross-build vanaf Mac (Apple Silicon → amd64-NAS) Alternatief voor de in-place build hierboven. Bouw de image op je Mac voor `linux/amd64`, schrijf 'm naar een tarball, transfer naar de NAS en laad daar. Handig als de NAS te langzaam is om te builden (npm install op een N5095 met NAS-storage is traag) of als je geen `git push` wilt voor elke iteratie. **Vóór je begint:** controleer dat `/share/Agent` een echte QTS Shared Folder is (zie [Vereisten op de NAS](#vereisten-op-de-nas)). Dat is de meest voorkomende val. ### 1. Tokens en `.env` op je Mac Zelfde tokens als in [Deploy](#deploy). Hou er rekening mee dat je **twee `.env`-bestanden** kunt willen: één voor lokaal Mac-testen (`AGENT_PLATFORM=linux/arm64`, `AGENT_UID=501`, paths onder `/Users/...`) en één voor NAS-runtime. De NAS-versie wordt bij stap 4 ge-scp'd en met `sed` geschikt gemaakt. ### 2. Image bouwen voor amd64 ```bash cd /Users//Development/scrum4me-docker docker buildx build \ --platform linux/amd64 \ --build-arg MCP_GIT_REF=main \ --build-arg CLAUDE_CODE_VERSION=latest \ --build-arg AGENT_UID=1000 \ --build-arg AGENT_GID=1000 \ -t scrum4me-agent-runner:local \ --load \ . ``` `AGENT_UID/GID=1000` zijn de **NAS-UIDs**, niet je Mac-UIDs (vaak `501/20`). Bij verkeerde UIDs kan de container niet schrijven naar de bind-mounts. Verifieer architectuur: ```bash docker image inspect scrum4me-agent-runner:local --format '{{.Architecture}}' # verwacht: amd64 ``` ### 3. Image naar tarball ```bash docker save scrum4me-agent-runner:local | gzip > scrum4me-agent-runner-amd64.tar.gz shasum -a 256 scrum4me-agent-runner-amd64.tar.gz ``` De tarball is ~580 MB voor de huidige image-size. Hij staat in `.gitignore`, dus geen risico op accidenteel committen. ### 4. NAS voorbereiden + bestanden transferren ```bash # .env naar de NAS (de Mac-versie; we patchen path-velden zo direct) scp .env admin@:/share/Agent/scrum4me-agent-runner/.env # image-tarball + bijgewerkte compose scp scrum4me-agent-runner-amd64.tar.gz docker-compose.yml package.json README.md \ admin@:/share/Agent/scrum4me-agent-runner/ ``` Zet de NAS-specifieke waarden in `.env`: ```bash ssh admin@ " source /etc/profile cd /share/Agent/scrum4me-agent-runner sed -i \ -e 's|^NAS_BASE=.*|NAS_BASE=/share/Agent|' \ -e 's|^AGENT_BASE=.*|AGENT_BASE=/share/Agent|' \ -e 's|^AGENT_PLATFORM=.*|AGENT_PLATFORM=linux/amd64|' \ -e 's|^AGENT_UID=.*|AGENT_UID=1000|' \ -e 's|^AGENT_GID=.*|AGENT_GID=1000|' \ -e 's|^AGENT_HEALTH_PORT_HOST=.*|AGENT_HEALTH_PORT_HOST=18080|' \ .env chmod 600 .env " ``` > **`source /etc/profile` is verplicht** in non-interactieve ssh op QNAP. Zonder > die regel staat `docker` niet op `$PATH` (Container Station's binary zit onder > `/share/CACHEDEV*_DATA/.qpkg/container-station/...`) en faalt elk > `docker`-commando met `command not found`. ### 5. Image laden + container recreaten ```bash ssh admin@ " source /etc/profile set -e cd /share/Agent/scrum4me-agent-runner # checksum-verificatie (optioneel maar aan te raden) sha256sum scrum4me-agent-runner-amd64.tar.gz # image laden — overschrijft bestaande :local tag gunzip -c scrum4me-agent-runner-amd64.tar.gz | docker load # container recreate; --no-build voorkomt onbedoelde NAS-side build docker compose up -d --no-build --force-recreate agent docker compose ps curl -fsS http://localhost:18080/health | head -c 800 " ``` Verwacht in de health-output `cache_free_bytes` met een groot getal (TB-orde) — dat is je signaal dat `/var/cache` op echte disk zit. Een waarde rond `16 MB` (`16777216`) betekent dat je per ongeluk nog steeds op tmpfs draait. ## Updaten (handmatig, bewust) `SCRUM4ME_TOKEN` of `CLAUDE_CODE_OAUTH_TOKEN` rouleer je via een rebuild: ```bash cd /share/Agent/scrum4me-agent-runner git pull vi .env # nieuwe waarden docker compose build # nieuwe scrum4me-mcp-versie als dat veranderd is docker compose up -d ``` Dezelfde flow voor schema-drift in scrum4me-mcp: pin een nieuwe `MCP_GIT_REF` in `.env` of in `docker-compose.yml`, rebuild. ### Wijzigingen in `docker-compose.yml` (volumes, tmpfs, env_file, ports) > **Let op:** `docker compose restart` herstart alleen het proces in de > bestaande container met de **oude** config. Wijzigingen in volumes, > tmpfs-mounts, env_file of ports worden daarmee **niet** doorgevoerd. Gebruik altijd `--force-recreate` als je `docker-compose.yml` is veranderd: ```bash docker compose up -d --force-recreate agent ``` Verifieer daarna dat `/var/cache` op de NAS-overlay staat en **niet** op tmpfs: ```bash docker exec scrum4me-agent df -h /var/cache # Verwacht: Filesystem op /dev/mapper/cachedev* of een NAS-share # Fout: tmpfs 16M ... (dan is force-recreate niet uitgevoerd) ``` ## Health-endpoint `GET http://:18080/health` retourneert: ```json { "status": "running", // running | idle | unhealthy | token-expired "lastBatchAt": "2026-05-01T12:34:56Z", "lastBatchExit": 0, "consecutiveFailures": 0, "tokenStatus": { "anthropic": "ok", "scrum4me": "ok", "db": "ok" } } ``` HTTP-status: `200` als running/idle, `503` bij token-expired of als de laatste heartbeat ouder is dan 5 minuten. ## Filesystem-grenzen De agent-user heeft geen SSH-keys en geen toegang tot andere shares dan `/share/Agent/*`. Wel een `~/.git-credentials` met de `GH_TOKEN` voor HTTPS-clone/push (zie volgende sectie) — die token is scoped tot de twee configured repos en mag worden gerouleerd door rebuild + redeploy. ## Repo bootstrap (clone-on-start) Bij elke container-start runt `bin/repo-bootstrap.sh` (als de `agent`-user, ná drop-privileges) en zet zo'n setup neer: 1. Configureert git's credential-helper met `GH_TOKEN` zodat `git clone`/`push` naar `https://github.com/...` zonder prompt werkt. 2. Voor elke repo in `GH_PRECLONE_REPOS` (komma-gescheiden owner/name): - Bestaat `~/Projects//.git` al? → `git fetch origin --prune` - Anders → fresh `git clone` Daarna vindt scrum4me-mcp's `resolveRepoRoot` (in `wait_for_job`) de clone via z'n convention-fallback `~/Projects//.git`. Worktrees voor jobs landen vervolgens onder `~/.scrum4me-agent-worktrees//` zodat de hoofd-clone niet wordt aangeraakt. Push gaat over dezelfde token: `git push -u origin feat/story-` slaagt zonder prompt. `gh pr create` (voor producten met `auto_pr=true`) gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect. ## Veelvoorkomende issues Verzameld uit echte deploy-sessies op een TS-664 met QTS 5.x. Eerst checken voor je gaat tweaken. ### `/share/Agent` is `tmpfs 16M` — scp faalt op grote files **Symptoom:** ``` scp: write remote ".../scrum4me-agent-runner-amd64.tar.gz": Failure ``` Eerst 100 % bij kleine files, dan blijvend "Failure" zodra een file de tmpfs vol maakt. **Oorzaak:** `/share/Agent` is geen geregistreerde QTS Shared Folder maar een gewone directory in de root-tmpfs. Na elke reboot is alle inhoud weg en is `/share/Agent` slechts een entry in de 16 MB `/share`-tmpfs. **Fix:** maak `Agent` aan via Control Panel → Privilege → Shared Folders → Create. Daarna verschijnt automatisch `lrwxrwxrwx /share/Agent → /share/CACHEDEV1_DATA/Agent`. Als die symlink ontbreekt omdat er al een tmpfs-directory `/share/Agent` staat: ```bash ssh admin@ " source /etc/profile docker stop scrum4me-agent 2>/dev/null docker rm scrum4me-agent 2>/dev/null rm -rf /share/Agent ln -s /share/CACHEDEV1_DATA/Agent /share/Agent mkdir -p /share/Agent/{cache,logs,state,scrum4me-agent-runner} ls -la /share/ | grep Agent # moet nu een symlink tonen " ``` > **`rm -rf /share/Agent` is veilig** zolang `/share/Agent` nog tmpfs is — > alle content stond in RAM en zou de volgende reboot toch verdwenen zijn. > Maar verifieer eerst met `df -h /share/Agent` dat 't echt tmpfs is. ### `docker: command not found` in non-interactieve ssh **Symptoom:** ```bash ssh admin@ 'docker ps' # sh: line 1: docker: command not found ``` **Oorzaak:** QNAP's `admin`-user heeft `docker` op `$PATH` via `/etc/profile.d/*.sh` van Container Station. Login-shells laden die scripts; non-interactieve `ssh user@host 'cmd'` doet dat **niet**. **Fix:** `source /etc/profile` aan het begin van je remote command: ```bash ssh admin@ "source /etc/profile && docker ps" ``` ### `yaml: control characters are not allowed` **Symptoom:** ```bash docker compose down # yaml: control characters are not allowed ``` **Oorzaak:** een eerdere `scp` van `docker-compose.yml` faalde halverwege en liet een file met NUL-bytes / partial writes achter. De file-grootte klopt maar de inhoud is corrupt. **Fix:** scp opnieuw vanaf je werkstation. Voor het stoppen van een al- draaiende container heb je de yml niet nodig: ```bash docker stop scrum4me-agent && docker rm scrum4me-agent ``` Daarna `scp docker-compose.yml admin@:/share/Agent/scrum4me-agent-runner/` en pas dan `docker compose up -d --no-build --force-recreate`. ### QTS-console-menu Python-traceback bij ssh-commando **Symptoom:** lange Python-traceback over `consolemenu_q/prompt_utils.py:31` en `Inappropriate ioctl for device`, gevolgd door je daadwerkelijke output. **Oorzaak:** QTS' admin-shell start een Python-TUI (qts-console-menu) die crasht zonder echte TTY. Niet-fataal — je commando wordt alsnog gerund. **Fix:** negeer de traceback. Als je 'm écht weg wilt, gebruik `ssh -tt admin@ '...'` voor een geforceerde pseudo-TTY, maar pas op: sommige scripts hangen daarop omdat ze interactie verwachten. ### `.env` is weg na `rm -rf /share/Agent` **Symptoom:** na het opruimen van een tmpfs-`/share/Agent` weigert `docker compose up`: ``` env file /share/Agent/scrum4me-agent-runner/.env not found ``` **Oorzaak:** `.env` zit in `.gitignore` en wordt nooit door `git pull` of `scp .` automatisch teruggezet. Bij het wegnuken van een corrupte share verdwijnt-ie mee. **Fix:** scp 'm vanaf je werkstation, en patch path-velden op de NAS-zijde (zie [Deploy — cross-build](#deploy--cross-build-vanaf-mac-apple-silicon--amd64-nas) stap 4). Zorg dat je `.env` op je werkstation **alle** secrets bevat — vooral `SCRUM4ME_TOKEN`, dat in een Mac-only `.env` makkelijk ontbreekt omdat lokale tests soms zonder Scrum4Me-API draaien. ## Bekende grenzen - **Eén actieve job tegelijk.** De wrapper-loop is sequentieel. Voor parallellisme zou je meerdere containers met dezelfde `SCRUM4ME_TOKEN` kunnen draaien — `wait_for_job` gebruikt `FOR UPDATE SKIP LOCKED` dus dat is veilig op DB-niveau, maar dan moet je je `node_modules`-cache per container scheiden. - **OAuth-token: 1 jaar geldig.** Bij verloop schrijft de wrapper een `TOKEN_EXPIRED`-marker en wordt de container `unhealthy`. Geen auto-rotatie. - **`npm install` per job** kost op een N5095 ~30–60 s per Next.js-clone, óók met de pnpm-store. Voor zeer kleine fixes is dat de dominante factor. Kan later vervangen worden door een persistente warm-`node_modules` per repo als dat een knelpunt wordt.