Compare commits

..

No commits in common. "master" and "fix/state-perms-and-log-cleanup" have entirely different histories.

15 changed files with 145 additions and 1100 deletions

View file

@ -1,16 +0,0 @@
# .env.deploy — config voor bin/deploy-to-nas.sh
# Kopieer naar .env.deploy en pas aan; staat in .gitignore.
# SSH-target voor de NAS. Moet werkend zijn met SSH-key (geen password).
NAS_HOST=admin@nas.local
# Pad op de NAS waar tarball + compose + .env terechtkomen.
# Default: /share/Agent/scrum4me-agent-runner
# NAS_REMOTE_DIR=/share/Agent/scrum4me-agent-runner
# Build-args (overrides). Standaard:
# MCP_GIT_REF=main — pin een commit in productie indien gewenst
# CLAUDE_CODE_VERSION=latest — pin een claude-code release indien gewenst
# AGENT_UID=1000, AGENT_GID=1000
# MCP_GIT_REF=main
# CLAUDE_CODE_VERSION=latest

View file

@ -22,29 +22,27 @@ CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-vervang-mij
# Als deze ge-revoked wordt: rebuild + redeploy (zie README). # Als deze ge-revoked wordt: rebuild + redeploy (zie README).
SCRUM4ME_TOKEN=vervang-mij SCRUM4ME_TOKEN=vervang-mij
# ----- Forgejo credentials (PBI-86 hybride model) ----------- # ----- GitHub credentials -----------------------------------
# Personal Access Token van je Forgejo-account met scope read+write # Personal Access Token (fine-grained) met:
# op de Scrum4Me-repos. Variabele heet historisch nog `GH_TOKEN`; # - Repository access op madhura68/Scrum4Me + madhura68/scrum4me-mcp
# in het hybride model bevat 'ie een Forgejo-PAT. # - Permissions: Contents (read/write), Pull requests (read/write),
# Metadata (read)
# #
# Gebruikt voor: # Gebruikt voor:
# 1. Pre-clone van de repos in /home/agent/Projects/<name>/ bij # 1. Pre-clone van de repos in /home/agent/Projects/<name>/ bij
# container-start (entrypoint.sh) # container-start (entrypoint.sh)
# 2. `git push` van agent feature-branches naar Forgejo via HTTPS # 2. `git push` van agent feature-branches via HTTPS
# 3. `gh pr create` (auto_pr=true) — gh CLI leest GH_TOKEN uit env
# #
# `gh pr create` is uit de worker-flow verwijderd (PBI-86, T-1005); # Genereer op github.com → Settings → Developer settings →
# de GitHub-PR ontstaat via de handmatig getriggerde promote-Action # Personal access tokens → Fine-grained tokens.
# in Forgejo. GH_TOKEN=ghp_vervang-mij
#
# Genereer in Forgejo: avatar → Settings → Applications →
# Generate New Token; scope minimaal `write:repository`.
GH_TOKEN=vervang-mij
# Lijst (komma-gescheiden) van repos om vooraf te clonen naar # Lijst (komma-gescheiden) van repos om vooraf te clonen naar
# ~agent/Projects/<name>. resolveRepoRoot in scrum4me-mcp valt # ~agent/Projects/<name>. resolveRepoRoot in scrum4me-mcp valt
# automatisch terug op die conventie. `<owner>/<repo>` zoals 'ie op # automatisch terug op die conventie. Voeg meer toe als je nieuwe
# Forgejo staat. Voeg meer toe als je nieuwe producten/repos toevoegt. # producten/repos toevoegt aan Scrum4Me.
GH_PRECLONE_REPOS=janpeter/Scrum4Me,janpeter/scrum4me-mcp GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp
# ----- Git commit-author ------------------------------------- # ----- Git commit-author -------------------------------------
# Verplicht — Vercel weigert deploys waarvan de commit-author email # Verplicht — Vercel weigert deploys waarvan de commit-author email
@ -110,9 +108,3 @@ AGENT_BACKOFF_MAX=300
AGENT_LOG_GZIP_AFTER_HOURS=24 AGENT_LOG_GZIP_AFTER_HOURS=24
# Hoeveel dagen ge-gzipte logs bewaren voor we ze verwijderen. # Hoeveel dagen ge-gzipte logs bewaren voor we ze verwijderen.
AGENT_LOG_DELETE_AFTER_DAYS=30 AGENT_LOG_DELETE_AFTER_DAYS=30
# Claude CLI --output-format. Default 'stream-json' streamt de volledige
# event-stream (tool-calls, berichten) live naar de run-log; 'text' geeft
# alleen Claude's eind-samenvatting (terser, maar geen live-meekijken).
# stream-json maakt de run-log JSONL — gebruik jq of een viewer.
AGENT_CLAUDE_OUTPUT_FORMAT=stream-json

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
# Secrets # Secrets
.env .env
.env.deploy
*.env.local *.env.local
# Local dev overrides (niet committen, per ontwikkelaar) # Local dev overrides (niet committen, per ontwikkelaar)

138
CLAUDE.md
View file

@ -1,85 +1,89 @@
# CLAUDE.md — Scrum4Me NAS-runner # CLAUDE.md — Scrum4Me NAS-runner
Je draait als headless worker op een QNAP NAS (of lokale Docker). Dit document Je draait als headless worker op een QNAP NAS. Dit document beschrijft
wordt automatisch geladen door `claude -p` vanuit `/opt/agent/` en geeft je de je rol; het wordt automatisch geladen door `claude -p` vanuit
**identiteit** en de **hardstop-regels** voor deze container. De per-job `/opt/agent/`.
**workflow** krijg je in de prompt zelf van `bin/run-one-job.ts`.
## Architectuur (sinds queue-loop-refactor)
`bin/run-agent.sh` is de daemon-loop (backoff/health/log-rotation). Elke
iteratie roept hij `tsx /opt/agent/bin/run-one-job.ts` aan. Die runner doet:
1. `getAuth``tryClaimJob` (één job, atomically).
2. `getFullJobContext` → resolved `JobConfig` (PBI-67) + payload.
3. Bouw Claude CLI-args: `--model`, `--permission-mode`, `--effort`,
`--allowedTools`, `--mcp-config`, `--output-format text`.
4. `spawn 'claude' …` met cwd = worktree_path en een **kind-specifieke
prompt** (uit `scrum4me-mcp/src/prompts/<kind>/`).
5. Wacht op exit; cleanup; loop terug naar run-agent.sh.
**Eén Claude-invocation = één geclaimde job.** Jij voert alleen die ene
job uit en sluit dan af.
## Identiteit ## Identiteit
- Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet - Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet
als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id` als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id`
tonen jouw token. zullen jouw token tonen.
- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT) - Je hebt **geen push-rechten**. Geen SSH-keys op deze container, geen
of de `primary_worktree_path` (idea-jobs). Buiten die directory en `~/.gitconfig` met push-credentials. Lokale commits zijn welkom; pushen
`/var/log/agent` heb je niets te zoeken. is iets wat de eindgebruiker zelf doet na review.
- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')` - Je opereert binnen `/tmp/job-<id>` per job. Buiten die directory en
aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`). buiten `/var/log/agent` heb je niets te zoeken.
## Hardstop-regels (gelden ongeacht je kind) ## Operationele loop (verplicht)
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"*
je geclaimd. Eén invocation = één job. of equivalent:
- **GEEN** `mcp__scrum4me__check_queue_empty`. Sluit af na deze ene job.
- **GEEN** `mcp__scrum4me__job_heartbeat` voor SPRINT_IMPLEMENTATION. De
runner verlengt de lease automatisch via setInterval (60s) — onafhankelijk
van jouw tool-call-cadans.
- **Geen handmatige `git push` of `gh pr create`.** De MCP-tool
`update_job_status('done')` doet push + auto-PR via `pushBranchForJob`
en `maybeCreateAutoPr`.
- **Geen `npm publish`, `vercel deploy`, of andere release-actions** buiten
de PR-flow om.
- **Geen long-running processes** (servers, watchers). Builds en tests
moeten zelfstandig terminaten.
- **Geen edits buiten `worktree_path` of `/tmp/job-*`.**
- **Geen credentials uitprinten** of in commits stoppen.
## Project-CLAUDE.md (in worktree) 1. Roep `mcp__scrum4me__wait_for_job` aan. Geen argumenten, geen wait-time
tweaken — de tool blokt zelf tot 600 s.
De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude 2. Als er een job geclaimd wordt:
automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de 1. Roep `bash /opt/agent/bin/job-prepare.sh <job_id> <repo_url>` aan
Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die via Bash. Output is het pad van de working tree.
bevat de ST-code-commit-stijl, lint/test/build-commands, en project- 2. `cd` naar dat pad.
specifieke patronen. 3. Lees de project-CLAUDE.md (`./CLAUDE.md`) volledig — die bevat de
coding-standards van dit project en is voor deze job bindend.
4. Voer het `implementation_plan` uit dat je van `wait_for_job` kreeg.
Volg de Commit Strategy uit de project-CLAUDE.md (commit per laag,
ST-code in de titel).
5. Voer de project-verificaties uit die de project-CLAUDE.md voorschrijft
(typisch `npm run lint && npm test && npm run build`).
6. **Niet pushen.** Lokaal committen op een feature-branch is goed.
7. Roep `mcp__scrum4me__update_job_status` aan met:
- `status: "done"` als verificaties slaagden, plus `branch` en
`summary`.
- `status: "failed"` met `error` als iets onomkeerbaar misging.
8. Roep `mcp__scrum4me__check_queue_empty` aan (geen args). Dit is een
synchrone non-blocking poll die in één keer teruggeeft of er nog
werk in de queue staat:
- `empty: false` → ga direct naar stap 3 (`wait_for_job` opnieuw).
- `empty: true` → batch is klaar; geef recap en exit. Geen extra
`wait_for_job`-call die 600 s blokt.
9. Roep `bash /opt/agent/bin/job-cleanup.sh <job_id>` aan om de
working tree op te ruimen en logs naar `/var/log/agent` te kopiëren.
3. Op basis van stap 8: bij `empty: false` opnieuw `wait_for_job`; bij
`empty: true` direct naar stap 4. Stop niet midden in de loop, vraag
niets.
4. Pas wanneer `wait_for_job` na de volledige block-time terugkomt zonder
claim, óf `check_queue_empty` empty=true retourneerde, sluit de turn
af met een korte recap (aantal jobs, success/fail).
## Foutscenario's ## Foutscenario's
- **Verificatie faalt** (lint/test/build rood): roep - **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer
`update_job_status('failed', error: <tail>)` aan en sluit af. Geen `update_job_status('failed', error=...)` en ga door met de volgende job.
automatische fix-attempts; de eindgebruiker beslist. Niet retry'en — als de cache stuk is, zal de volgende job ook falen en
- **Verify-gate DIVERGENT**: roep `verify_task_against_plan` opnieuw aan zal de wrapper merken dat we te veel fouten op rij hebben.
met een `summary` die de afwijking onderbouwt, óf rapporteer `failed`. - **Verificatie faalt** (lint/test/build rood): rapporteer `failed` met
- **Onverwachte runtime-fout**: laat de exception propageren. De runner de tail van de output in `error`. Geen automatische fix-attempts; de
detecteert exit≠0 zonder `update_job_status` en doet rollbackClaim; eindgebruiker beslist of ze het plan aanpassen.
de wrapper-loop in run-agent.sh schrijft een run-log en herstart met - **Onverwachte runtime-fout** in de tools: laat de exception propageren.
backoff. De wrapper-loop schrijft een run-log en herstart `claude -p` met backoff.
## Vraag-antwoord-kanaal (M11) ## Vraag-antwoord-kanaal (M11)
Voor blokkerende keuzes die niet uit het plan volgen: gebruik Als het `implementation_plan` ambigu is op een keuze die niet uit de
`mcp__scrum4me__ask_user_question` met 24 `options` en `wait_seconds: 600`. acceptance-criteria volgt: gebruik `mcp__scrum4me__ask_user_question`
Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord met een korte vraag plus 24 `options`. Geef `wait_seconds: 600` mee
op vraag <id>")`. Niet gokken. Niet aannemen. zodat de tool blijft wachten. Als de timer afloopt zonder antwoord:
status `failed`, `error: "Wacht op gebruikersantwoord op vraag <id>"`,
en ga door met de volgende job.
## Verwijzingen Niet gokken. Niet aannemen.
- Per-kind workflows: zie de prompt die de runner je in `claude -p` meegeeft ## Wat je NIET doet
(komt uit `scrum4me-mcp/src/prompts/<kind>/`).
- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo. - Geen `git push`, ook niet naar `origin/<branch>` van een feature-branch.
- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo. - Geen `npm publish`, `vercel deploy`, of welke release-actie dan ook.
- Geen edits buiten `/tmp/job-*` (geen `~/.bashrc`, geen `/etc/...`,
geen andere shares).
- Geen credentials uitprinten of in commit-messages stoppen — `.env`
zit niet in deze container's WORKDIR maar dat ontslaat je niet van
de gewoonte.
- Geen long-running shell-processes starten (servers, watchers). Builds
en tests moeten zelfstandig terminate'n.

View file

@ -53,15 +53,10 @@ RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} \
# Clone zonder submodules — de Prisma-schema zit al gecommit in het repo. # Clone zonder submodules — de Prisma-schema zit al gecommit in het repo.
# De vendor/scrum4me submodule is alleen nodig om het schema te updaten, # De vendor/scrum4me submodule is alleen nodig om het schema te updaten,
# niet om te builden. Pin via build-arg; default = main. # niet om te builden. Pin via build-arg; default = main.
ARG MCP_GIT_REPO=https://git.jp-visser.nl/janpeter/scrum4me-mcp.git ARG MCP_GIT_REPO=https://github.com/madhura68/scrum4me-mcp.git
ARG MCP_GIT_REF=main ARG MCP_GIT_REF=main
# Cache-bust voor de clone-laag: hetzelfde MCP_GIT_REF kan tussen rebuilds
# een ander commit aanwijzen (bv. main na een merge). Geef als build-arg
# `--build-arg MCP_CACHE_BUST=$(date +%s)` mee om deze laag te invalidaten.
ARG MCP_CACHE_BUST=1
RUN echo "cache-bust=${MCP_CACHE_BUST}" \ RUN git clone --branch ${MCP_GIT_REF} --depth 1 \
&& git clone --branch ${MCP_GIT_REF} --depth 1 \
${MCP_GIT_REPO} /opt/scrum4me-mcp \ ${MCP_GIT_REPO} /opt/scrum4me-mcp \
&& cd /opt/scrum4me-mcp \ && cd /opt/scrum4me-mcp \
&& npm ci --omit=dev --omit=optional || npm install --omit=dev \ && npm ci --omit=dev --omit=optional || npm install --omit=dev \
@ -96,17 +91,6 @@ COPY --chown=agent:agent mcp-config.json ./
RUN chmod +x ./bin/*.sh RUN chmod +x ./bin/*.sh
# ----- usage-capture hook ------------------------------------------------
# Claude Code start met cwd=/opt/agent en HOME=/home/agent. Zonder dit
# kopieerblok ziet Claude Code geen .claude/settings.json en fireert de
# PostToolUse-hook van scrum4me-mcp niet — token-tellers op claude_jobs
# blijven dan NULL. Plaats de hook-config in de user-scope settings zodat
# 'm activeert ongeacht de cwd waarin de agent draait.
RUN install -d -o agent -g agent /home/agent/.claude \
&& install -m 0644 -o agent -g agent \
/opt/scrum4me-mcp/.claude/settings.json \
/home/agent/.claude/settings.json
# ----- runtime config ---------------------------------------------------- # ----- runtime config ----------------------------------------------------
ENV PATH=/opt/agent/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ ENV PATH=/opt/agent/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
HOME=/home/agent \ HOME=/home/agent \
@ -116,9 +100,7 @@ ENV PATH=/opt/agent/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:
AGENT_LOG_DIR=/var/log/agent \ AGENT_LOG_DIR=/var/log/agent \
AGENT_REPO_CACHE=/var/cache/repos \ AGENT_REPO_CACHE=/var/cache/repos \
AGENT_JOB_ROOT=/tmp \ AGENT_JOB_ROOT=/tmp \
AGENT_HEALTH_PORT=8080 \ AGENT_HEALTH_PORT=8080
SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp \
NODE_PATH=/opt/scrum4me-mcp/node_modules
EXPOSE 8080 EXPOSE 8080

312
README.md
View file

@ -13,7 +13,7 @@ nooit zelf.
│ │ │ │
│ ┌─ container: agent-runner ────────────────────────────────┐ │ │ ┌─ container: agent-runner ────────────────────────────────┐ │
│ │ PID 1: tini → run-agent.sh (daemon-loop) │ │ │ │ PID 1: tini → run-agent.sh (daemon-loop) │ │
│ │ ├─ health-server.js (8080 → host 18080) │ │ │ │ ├─ health-server.js (8080 → host) │ │
│ │ └─ claude -p (per-batch, met MCP via stdio) │ │ │ │ └─ claude -p (per-batch, met MCP via stdio) │ │
│ │ └─ scrum4me-mcp → Neon Postgres │ │ │ │ └─ scrum4me-mcp → Neon Postgres │ │
│ │ │ │ │ │ │ │
@ -52,30 +52,16 @@ fouten.
| `bin/check-tokens.sh` | Pre-flight: API-token, OAuth-token, DB-bereikbaarheid | | `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-<id>` | | `bin/job-prepare.sh` | Per-job: bare-fetch + clone-via-reference naar `/tmp/job-<id>` |
| `bin/job-cleanup.sh` | Per-job: logs naar `/var/log`, working tree weg | | `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/health-server.js` | HTTP-endpoint op 8080 dat state.json en marker-files leest |
| `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden | | `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden |
| `.env.example` | Alle env-vars met uitleg | | `.env.example` | Alle env-vars met uitleg |
## Vereisten op de NAS ## Vereisten op de NAS
- Container Station 2+ (Docker compose v2) - Container Station 2+ (Docker compose v2)
- **`Agent` als QTS Shared Folder** op een echte volume (bv. `CACHEDEV1_DATA`). - Drie shares aangemaakt: `/share/Agent/cache`, `/share/Agent/logs`, `/share/Agent/state`
Niet een `mkdir /share/Agent``/share` zelf is een 16 MB tmpfs en handmatige - Of één share `/share/Agent` waaronder de drie subdirs vallen
directories overleven geen reboot. Aanmaken via Control Panel → Privilege → - Internet-uitgang naar `api.anthropic.com`, `github.com`, je Neon-host, `registry.npmjs.org`
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`, `git.jp-visser.nl` (Forgejo HTTPS-clone/push), `cli.github.com` (build-time voor de gh CLI), je Neon-host, `registry.npmjs.org`.
> **Verifieer** vóór je deployt dat `/share/Agent` echt op disk staat:
> ```bash
> ssh admin@<nas> '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 ## Deploy
@ -85,21 +71,18 @@ fouten.
# b. SCRUM4ME_TOKEN → log in als de dedicated agent-user in # b. SCRUM4ME_TOKEN → log in als de dedicated agent-user in
# Scrum4Me, /settings/tokens, label "NAS-runner" # Scrum4Me, /settings/tokens, label "NAS-runner"
# c. DATABASE_URL/DIRECT_URL → Neon dashboard # c. DATABASE_URL/DIRECT_URL → Neon dashboard
# d. GH_TOKEN → Forgejo → avatar → Settings → # d. GH_TOKEN → github.com → Settings → Developer settings →
# Applications → Generate New Token; scope # Personal access tokens → Fine-grained.
# minimaal `write:repository` op de twee # Repository access op madhura68/Scrum4Me +
# repos (janpeter/Scrum4Me + janpeter/ # madhura68/scrum4me-mcp; Permissions:
# scrum4me-mcp). Wordt gebruikt voor clone # Contents (RW), Pull requests (RW),
# en push naar Forgejo. PBI-86 (hybride # Metadata (R). Wordt gebruikt voor clone,
# model): `gh pr create` is uit de # push en `gh pr create` (auto_pr).
# worker-flow verwijderd — de GitHub-PR
# komt via de handmatige promote-Action
# in Forgejo.
# 2. Repo op de NAS plaatsen # 2. Repo op de NAS plaatsen
ssh admin@nas ssh admin@nas
cd /share/Agent cd /share/Agent
git clone https://git.jp-visser.nl/<jij>/scrum4me-agent-runner.git git clone https://github.com/<jij>/scrum4me-agent-runner.git
cd scrum4me-agent-runner cd scrum4me-agent-runner
# 3. Env aanmaken # 3. Env aanmaken
@ -112,158 +95,10 @@ docker compose build
docker compose up -d docker compose up -d
# 5. Verifiëren # 5. Verifiëren
curl http://nas.local:18080/health curl http://nas.local:8080/health
docker compose logs -f 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@<nas>
# 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/<jij>/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@<nas>:/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@<nas>:/share/Agent/scrum4me-agent-runner/
```
Zet de NAS-specifieke waarden in `.env`:
```bash
ssh admin@<nas> "
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@<nas> "
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) ## Updaten (handmatig, bewust)
`SCRUM4ME_TOKEN` of `CLAUDE_CODE_OAUTH_TOKEN` rouleer je via een rebuild: `SCRUM4ME_TOKEN` of `CLAUDE_CODE_OAUTH_TOKEN` rouleer je via een rebuild:
@ -301,7 +136,7 @@ docker exec scrum4me-agent df -h /var/cache
## Health-endpoint ## Health-endpoint
`GET http://<nas>:18080/health` retourneert: `GET http://<nas>:8080/health` retourneert:
```json ```json
{ {
@ -329,8 +164,7 @@ Bij elke container-start runt `bin/repo-bootstrap.sh` (als de
`agent`-user, ná drop-privileges) en zet zo'n setup neer: `agent`-user, ná drop-privileges) en zet zo'n setup neer:
1. Configureert git's credential-helper met `GH_TOKEN` zodat 1. Configureert git's credential-helper met `GH_TOKEN` zodat
`git clone`/`push` naar `https://git.jp-visser.nl/...` (Forgejo) zonder `git clone`/`push` naar `https://github.com/...` zonder prompt werkt.
prompt werkt.
2. Voor elke repo in `GH_PRECLONE_REPOS` (komma-gescheiden owner/name): 2. Voor elke repo in `GH_PRECLONE_REPOS` (komma-gescheiden owner/name):
- Bestaat `~/Projects/<name>/.git` al? → `git fetch origin --prune` - Bestaat `~/Projects/<name>/.git` al? → `git fetch origin --prune`
- Anders → fresh `git clone` - Anders → fresh `git clone`
@ -341,118 +175,8 @@ voor jobs landen vervolgens onder `~/.scrum4me-agent-worktrees/<jobId>/`
zodat de hoofd-clone niet wordt aangeraakt. zodat de hoofd-clone niet wordt aangeraakt.
Push gaat over dezelfde token: `git push -u origin feat/story-<id>` Push gaat over dezelfde token: `git push -u origin feat/story-<id>`
slaagt zonder prompt. **`gh pr create` is in PBI-86 (T-1005) verwijderd slaagt zonder prompt. `gh pr create` (voor producten met `auto_pr=true`)
uit de worker-flow** — de GitHub-PR ontstaat via een handmatig gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect.
getriggerde promote-Action in Forgejo (zie de Scrum4Me-repo
`docs/runbooks/forgejo-hybrid-flow.md`).
## 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@<nas> "
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@<nas> '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@<nas> "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@<nas>:/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@<nas> '...'` 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 ## Bekende grenzen

View file

@ -1,112 +0,0 @@
#!/usr/bin/env bash
# deploy-to-nas.sh — Mac → NAS cross-build deploy in één commando.
#
# Voert in volgorde uit:
# 1. docker buildx build (linux/amd64, --load)
# 2. docker save | gzip → scrum4me-agent-runner-amd64.tar.gz
# 3. scp tarball + docker-compose.yml + (eerste keer) .env naar NAS
# 4. ssh: docker load + sed-patch .env paths (eerste keer) + compose up --force-recreate
# 5. ssh: docker compose logs -f voor verificatie
#
# Gebruik:
# bin/deploy-to-nas.sh # gebruikt env vars uit .env.deploy
# NAS_HOST=admin@nas bin/deploy-to-nas.sh
#
# Vereisten:
# - docker buildx geïnstalleerd
# - .env aanwezig in repo-root (deze wordt naar NAS gestuurd op eerste run)
# - .env.deploy met NAS_HOST + NAS_REMOTE_DIR (optioneel — anders prompted)
# - SSH-key access naar de NAS (geen password-prompts)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
# ----- config -----------------------------------------------------------
if [[ -f .env.deploy ]]; then
# shellcheck disable=SC1091
source .env.deploy
fi
: "${NAS_HOST:?NAS_HOST not set — bv. admin@nas.local. Zet 'm in .env.deploy of als env-var.}"
: "${NAS_REMOTE_DIR:=/share/Agent/scrum4me-agent-runner}"
: "${IMAGE_TAG:=scrum4me-agent-runner:local}"
: "${TARBALL:=scrum4me-agent-runner-amd64.tar.gz}"
: "${MCP_GIT_REF:=main}"
: "${CLAUDE_CODE_VERSION:=latest}"
: "${AGENT_UID:=1000}"
: "${AGENT_GID:=1000}"
log() { echo "[deploy-to-nas] $*"; }
# ----- pre-flight -------------------------------------------------------
if [[ ! -f .env ]]; then
log "FAIL: .env ontbreekt in repo-root. Maak 'm aan via: cp .env.example .env"
exit 1
fi
# ----- 1. buildx --------------------------------------------------------
log "1/5 docker buildx build (linux/amd64, MCP_GIT_REF=$MCP_GIT_REF)"
docker buildx build \
--platform linux/amd64 \
--build-arg "MCP_GIT_REF=${MCP_GIT_REF}" \
--build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \
--build-arg "AGENT_UID=${AGENT_UID}" \
--build-arg "AGENT_GID=${AGENT_GID}" \
-t "$IMAGE_TAG" \
--load \
.
# ----- 2. tarball -------------------------------------------------------
log "2/5 docker save | gzip → $TARBALL"
docker save "$IMAGE_TAG" | gzip > "$TARBALL"
ls -lh "$TARBALL"
# ----- 3. scp -----------------------------------------------------------
log "3/5 scp tarball + compose naar $NAS_HOST:$NAS_REMOTE_DIR"
ssh "$NAS_HOST" "mkdir -p '$NAS_REMOTE_DIR'"
# Check of er al een .env op de NAS staat. Zo niet: stuur de Mac-versie en
# patch hem. Zo wel: laat 'm met rust (kan NAS-specifiek aangepast zijn).
if ssh "$NAS_HOST" "test -f '$NAS_REMOTE_DIR/.env'" 2>/dev/null; then
log " (.env bestaat al op NAS — niet overschreven)"
else
log " geen .env op NAS, kopiëren + patchen"
scp .env "$NAS_HOST:$NAS_REMOTE_DIR/.env"
ssh "$NAS_HOST" "
cd '$NAS_REMOTE_DIR'
chmod 600 .env
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=${AGENT_UID}|' \\
-e 's|^AGENT_GID=.*|AGENT_GID=${AGENT_GID}|' \\
-e 's|^AGENT_HEALTH_PORT_HOST=.*|AGENT_HEALTH_PORT_HOST=18080|' \\
.env
"
fi
scp "$TARBALL" docker-compose.yml package.json README.md \
"$NAS_HOST:$NAS_REMOTE_DIR/"
# ----- 4. load + restart ------------------------------------------------
log "4/5 docker load + compose up --force-recreate op NAS"
ssh "$NAS_HOST" "
set -eu
source /etc/profile
cd '$NAS_REMOTE_DIR'
# Sanity: env-vars die check-tokens.sh nodig heeft
grep -qE '^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=' .env || { echo 'FAIL: anthropic credential ontbreekt in .env'; exit 1; }
grep -qE '^SCRUM4ME_TOKEN=' .env || { echo 'FAIL: SCRUM4ME_TOKEN ontbreekt in .env'; exit 1; }
grep -qE '^DATABASE_URL=' .env || { echo 'FAIL: DATABASE_URL ontbreekt in .env'; exit 1; }
gunzip -c '$TARBALL' | docker load
docker compose up -d --force-recreate
"
# ----- 5. tail logs ------------------------------------------------------
log "5/5 docker compose logs (Ctrl-C om te stoppen)"
ssh "$NAS_HOST" "cd '$NAS_REMOTE_DIR' && docker compose logs -f --tail=50"

View file

@ -28,7 +28,7 @@ if [[ -z "$JOB_ID" || -z "$REPO_URL" ]]; then
exit 2 exit 2
fi fi
# Slug uit repo_url voor de cache-naam: "git.jp-visser.nl/foo/bar.git" → "foo_bar" # Slug uit repo_url voor de cache-naam: "github.com/foo/bar.git" → "foo_bar"
SLUG=$(echo "$REPO_URL" \ SLUG=$(echo "$REPO_URL" \
| sed -E 's#^.*[:/]([^/]+/[^/]+?)(\.git)?/?$#\1#' \ | sed -E 's#^.*[:/]([^/]+/[^/]+?)(\.git)?/?$#\1#' \
| tr '/' '_') | tr '/' '_')

View file

@ -18,9 +18,4 @@ find "${AGENT_LOG_DIR}" -type f \
\( -name '*.log' -o -name '*.log.gz' -o -name '*.txt' -o -name '*.json' \) \ \( -name '*.log' -o -name '*.log.gz' -o -name '*.txt' -o -name '*.json' \) \
-mtime "+${AGENT_LOG_HARD_DELETE_DAYS}" -delete 2>/dev/null || true -mtime "+${AGENT_LOG_HARD_DELETE_DAYS}" -delete 2>/dev/null || true
# Prune dangling per-job symlinks: jobs/<job_id>.log -> runs/<ts>.log waarvan
# het doel door rotatie is gegzipt of verwijderd. De -type f hierboven raakt
# symlinks niet, dus broken links worden hier expliciet opgeruimd (-xtype l).
find "${AGENT_LOG_DIR}/jobs" -maxdepth 1 -xtype l -delete 2>/dev/null || true
find "${AGENT_LOG_DIR}/jobs" -mindepth 1 -type d -empty -delete 2>/dev/null || true find "${AGENT_LOG_DIR}/jobs" -mindepth 1 -type d -empty -delete 2>/dev/null || true

View file

@ -32,8 +32,8 @@ fi
mkdir -p "$HOME" mkdir -p "$HOME"
git config --global credential.helper store git config --global credential.helper store
CREDS_FILE="$HOME/.git-credentials" CREDS_FILE="$HOME/.git-credentials"
if [[ ! -f "$CREDS_FILE" ]] || ! grep -q "oauth2:${GH_TOKEN}@git.jp-visser.nl" "$CREDS_FILE" 2>/dev/null; then if [[ ! -f "$CREDS_FILE" ]] || ! grep -q "oauth2:${GH_TOKEN}@github.com" "$CREDS_FILE" 2>/dev/null; then
printf 'https://oauth2:%s@git.jp-visser.nl\n' "$GH_TOKEN" > "$CREDS_FILE" printf 'https://oauth2:%s@github.com\n' "$GH_TOKEN" > "$CREDS_FILE"
chmod 600 "$CREDS_FILE" chmod 600 "$CREDS_FILE"
log "git credentials helper configured at ${CREDS_FILE}" log "git credentials helper configured at ${CREDS_FILE}"
fi fi
@ -71,7 +71,7 @@ for repo in "${REPOS[@]}"; do
else else
log "cloning ${repo} into ${target}" log "cloning ${repo} into ${target}"
rm -rf "$target" rm -rf "$target"
git clone --quiet "https://git.jp-visser.nl/${repo}.git" "$target" \ git clone --quiet "https://github.com/${repo}.git" "$target" \
|| { log "ERROR: clone failed for ${repo}"; continue; } || { log "ERROR: clone failed for ${repo}"; continue; }
fi fi
done done

View file

@ -3,19 +3,14 @@
# #
# Strategie: # Strategie:
# - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal) # - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal)
# - Loop: tsx /opt/agent/bin/run-one-job.ts (één geclaimde job per iteratie) # - Loop: claude -p met seed-prompt
# - Exit 0 → de queue was leeg of de job is afgerond, sleep kort, herhaal # - Exit 0 → de queue was leeg, sleep kort, herhaal
# - Exit 3 → run-one-job detecteerde TOKEN_EXPIRED in Claude-output
# - Exit ≠ 0 → exponential backoff, log, schrijf state, herhaal # - Exit ≠ 0 → exponential backoff, log, schrijf state, herhaal
# - Bij N opeenvolgende fouten → schrijf UNHEALTHY marker; health # - Bij N opeenvolgende fouten → schrijf UNHEALTHY marker; health
# endpoint gaat op 503, container blijft runnen voor diagnose # endpoint gaat op 503, container blijft runnen voor diagnose
# - Bij gedetecteerde token-expiry → schrijf TOKEN_EXPIRED marker # - Bij gedetecteerde token-expiry → schrijf TOKEN_EXPIRED marker
# en exit (compose start opnieuw, maar entrypoint zal dezelfde # en exit (compose start opnieuw, maar entrypoint zal dezelfde
# marker zien via health-server) # marker zien via health-server)
#
# Claim/exec-loop zit in bin/run-one-job.ts (Node + tsx); deze shell doet
# alleen daemon/backoff/health/log-rotation. Zie docs/plans/queue-loop-extraction.md
# in de Scrum4Me-repo.
set -uo pipefail # let op: geen -e, we willen exit-codes inspecteren set -uo pipefail # let op: geen -e, we willen exit-codes inspecteren
@ -45,10 +40,13 @@ rm -f "${AGENT_STATE_DIR}/UNHEALTHY" "${AGENT_STATE_DIR}/TOKEN_EXPIRED"
/opt/agent/bin/rotate-logs.sh || true /opt/agent/bin/rotate-logs.sh || true
/opt/agent/bin/log-cleanup.sh || true /opt/agent/bin/log-cleanup.sh || true
# Geen seed-prompt en geen ALLOWED_TOOLS-string meer: per-job CLI-flags # ----- seed prompt ------------------------------------------------------
# (incl. --model, --permission-mode, --effort, --allowedTools en de SEED_PROMPT='Pak de volgende job uit de Scrum4Me-queue en draai de queue leeg volgens de loop in /opt/agent/CLAUDE.md. Niet stoppen tussen jobs door. Sluit pas af zodra wait_for_job na de volledige block-time terugkomt zonder claim.'
# kind-specifieke prompt) worden door run-one-job.ts gebouwd uit
# JobConfig (resolved via PBI-67's resolveJobConfig). # Tools-allowlist: alle MCP-tools die scrum4me-mcp aanbiedt + standaard
# file/bash-tools. Geen WebFetch, geen WebSearch — de agent heeft die
# niet nodig en uitsluiting verkleint het surface.
ALLOWED_TOOLS='Read,Edit,Write,Bash,Grep,Glob,mcp__scrum4me__health,mcp__scrum4me__list_products,mcp__scrum4me__get_claude_context,mcp__scrum4me__wait_for_job,mcp__scrum4me__check_queue_empty,mcp__scrum4me__update_job_status,mcp__scrum4me__update_task_status,mcp__scrum4me__update_task_plan,mcp__scrum4me__log_implementation,mcp__scrum4me__log_test_result,mcp__scrum4me__log_commit,mcp__scrum4me__create_pbi,mcp__scrum4me__create_story,mcp__scrum4me__create_task,mcp__scrum4me__create_todo,mcp__scrum4me__ask_user_question,mcp__scrum4me__get_question_answer,mcp__scrum4me__list_open_questions,mcp__scrum4me__cancel_question'
CONSEC_FAILURES=0 CONSEC_FAILURES=0
BACKOFF=${AGENT_BACKOFF_START} BACKOFF=${AGENT_BACKOFF_START}
@ -62,31 +60,32 @@ while true; do
--argjson failures "$CONSEC_FAILURES" \ --argjson failures "$CONSEC_FAILURES" \
'{status:"running", currentBatchStartedAt:$started, consecutiveFailures:$failures}')" '{status:"running", currentBatchStartedAt:$started, consecutiveFailures:$failures}')"
log "starting iteration (log: ${run_log})" log "starting batch (log: ${run_log})"
# Eén iteratie = één geclaimde job (of "geen job" → exit 0). De runner # claude -p met onze MCP-config en allowlist.
# claimt zelf via tryClaimJob, leest JobConfig (PBI-67), bouwt de # cwd = /opt/agent zodat onze CLAUDE.md auto-geladen wordt.
# juiste Claude CLI-args, spawnt 'claude', wacht, sluit af. #
# --permission-mode bypassPermissions: alle resterende permission-
# prompts uit. Veilig in deze container omdat (1) we draaien als
# non-root agent-user, (2) geen push-credentials, (3) writes
# gelimiteerd tot /tmp/job-*. De allowlist hierboven blijft als
# belt-and-braces second filter.
set +e set +e
# RUN_LOG laat run-one-job.ts een jobs/<job_id>.log symlink leggen naar claude -p "${SEED_PROMPT}" \
# dit run-log, zodat de output van een job op job-id vindbaar is. --mcp-config /opt/agent/mcp-config.json \
RUN_LOG="${run_log}" tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 --allowedTools "${ALLOWED_TOOLS}" \
--permission-mode bypassPermissions \
--output-format text \
> "${run_log}" 2>&1
exit_code=$? exit_code=$?
set -e set -e
iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ) iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
log "batch ended exit=${exit_code}" log "batch ended exit=${exit_code}"
# Token-expiry detectie: run-one-job.ts retourneert exit 3 wanneer het # Token-expiry detectie: parse stderr/stdout op bekende strings.
# bekende auth-error-strings in Claude's output ziet. We checken óók de if grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then
# log-tekst voor het geval een ander pad het patroon raakt (bv. Prisma- log "AUTH FAILURE detected in run log — marking TOKEN_EXPIRED"
# connection-error met OAuth-expired in error-body) — maar alléén bij een
# niet-nul exit. Het run-log bevat de volledige stream-json output (incl.
# tool-results én run-one-job's eigen "TOKEN_EXPIRED detected"-logregel),
# dus een geslaagde job die toevallig "401 unauthorized" in z'n output
# heeft mag de grep-fallback niet triggeren.
if [[ "$exit_code" -eq 3 ]] || { [[ "$exit_code" -ne 0 ]] && grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; }; then
log "AUTH FAILURE detected (exit=$exit_code or pattern in log) — marking TOKEN_EXPIRED"
touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED" touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED"
write_state "$(jq -n \ write_state "$(jq -n \
--arg endedAt "$iteration_end" \ --arg endedAt "$iteration_end" \

View file

@ -1,450 +0,0 @@
#!/usr/bin/env tsx
// run-one-job.ts — handelt één geclaimde Scrum4Me-ClaudeJob af.
//
// Architectuur (zie docs/plans/queue-loop-extraction.md in Scrum4Me-repo):
// scrum4me-docker/bin/run-agent.sh roept dit script per iteratie aan.
// Stappen:
// 1. getAuth → resolved userId/tokenId uit SCRUM4ME_TOKEN.
// 2. quota-probe (was Claude's verantwoordelijkheid in CLAUDE.md stappen 0.x).
// 3. resetStaleClaimedJobs → tryClaimJob, met LISTEN-fallback (270s) bij lege queue.
// 4. getFullJobContext → resolved JobConfig + kind-specifieke payload.
// 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION).
// 6. Schrijf payload naar /tmp/job-<id>/payload.json.
// 7. Bouw CLI-args uit ctx.config + mapBudgetToEffort.
// 8. setInterval(60s) lease-renewal voor SPRINT_IMPLEMENTATION.
// 9. spawn 'claude' met inherited stdio + scan voor token-expiry-patterns.
// 10. try/finally: bij Claude-exit≠0 zonder update_job_status → rollbackClaim.
// 11. cleanup payload + prisma.$disconnect().
//
// Exit-codes:
// 0 = job afgehandeld of geen job binnen wait-deadline (idle)
// 1 = generieke fout (claim, context-fetch, worktree, spawn)
// 3 = TOKEN_EXPIRED detected → run-agent.sh schrijft TOKEN_EXPIRED marker
import { spawn, spawnSync } from 'node:child_process'
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { basename, join } from 'node:path'
import { Client as PgClient } from 'pg'
import { getAuth } from '/opt/scrum4me-mcp/src/auth.js'
import { prisma } from '/opt/scrum4me-mcp/src/prisma.js'
import {
attachWorktreeToJob,
getFullJobContext,
resetStaleClaimedJobs,
rollbackClaim,
tryClaimJob,
} from '/opt/scrum4me-mcp/src/tools/wait-for-job.js'
import { releaseLocksOnTerminal } from '/opt/scrum4me-mcp/src/git/job-locks.js'
import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js'
import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js'
import { registerWorker } from '/opt/scrum4me-mcp/src/presence/worker.js'
import { startHeartbeat } from '/opt/scrum4me-mcp/src/presence/heartbeat.js'
// ----- logging --------------------------------------------------------
const log = (msg: string) =>
console.log(`${new Date().toISOString()} [run-one-job] ${msg}`)
const logError = (msg: string) =>
console.error(`${new Date().toISOString()} [run-one-job] ERROR ${msg}`)
// ----- constants ------------------------------------------------------
const WAIT_DEADLINE_SECONDS = 270 // ruim binnen MAX_WAIT_SECONDS van wait_for_job
const POLL_INTERVAL_MS = 5000
const HEARTBEAT_INTERVAL_MS = 60_000
const WORKER_HEARTBEAT_INTERVAL_MS = 10_000
const MCP_CONFIG = '/opt/agent/mcp-config.json'
const QUOTA_PROBE_PATH = '/opt/agent/bin/worker-quota-probe.sh'
const QUOTA_BACKOFF_CAP_MS = 30 * 60 * 1000
const TOKEN_EXPIRY_PATTERNS: RegExp[] = [
/invalid_api_key/i,
/authentication.*failed/i,
/401.*unauthor/i,
/OAuth.*expired/i,
]
// ----- quota probe ----------------------------------------------------
// Soft-fail: als de probe geen rate-limit-headers krijgt (sommige Anthropic
// endpoints retourneren ze niet) of een transient netwerkfout heeft, log
// een warning en ga door. Alleen bij gemeten quota-overschrijding sleepen.
// Dit spiegelt het gedrag van CLAUDE.md stap 0.4 ("anders: ga door").
async function quotaProbe(userId: string): Promise<void> {
const probe = spawnSync(QUOTA_PROBE_PATH, [], { encoding: 'utf8' })
if (probe.status !== 0) {
log(
`quota probe non-zero status=${probe.status} stdout=${probe.stdout.slice(0, 200).trim()} — continuing without gate`,
)
return
}
let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string; error?: string }
try {
parsed = JSON.parse(probe.stdout)
} catch {
log(`quota probe stdout not JSON (continuing): ${probe.stdout.slice(0, 200)}`)
return
}
if (parsed.pct === undefined) {
log(`quota probe no pct (continuing): error=${parsed.error ?? '-'}`)
return
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { min_quota_pct: true },
})
const minPct = user?.min_quota_pct ?? 0
log(
`quota probe pct=${parsed.pct} min_quota_pct=${minPct} ` +
`reset_at=${parsed.reset_at_iso ?? '-'}`,
)
if (parsed.pct < minPct) {
let sleepMs = QUOTA_BACKOFF_CAP_MS
if (parsed.reset_at_iso) {
const resetAt = new Date(parsed.reset_at_iso).getTime()
const delta = resetAt - Date.now()
if (delta > 0 && delta < sleepMs) sleepMs = delta
}
log(`quota below min — sleeping ${Math.round(sleepMs / 1000)}s until reset`)
await new Promise((resolve) => setTimeout(resolve, sleepMs))
}
}
// ----- LISTEN-fallback voor lege queue -------------------------------
async function waitForEnqueue(userId: string): Promise<void> {
const dburl = process.env.DATABASE_URL
if (!dburl) throw new Error('DATABASE_URL not set')
const client = new PgClient({ connectionString: dburl })
await client.connect()
await client.query('LISTEN scrum4me_changes')
const deadline = Date.now() + WAIT_DEADLINE_SECONDS * 1000
try {
while (Date.now() < deadline) {
await new Promise<void>((resolve) => {
const pollTimer = setTimeout(resolve, POLL_INTERVAL_MS)
const onNotify = (msg: { payload?: string }) => {
try {
const payload = JSON.parse(msg.payload ?? '{}')
if (
payload.type === 'claude_job_enqueued' &&
payload.user_id === userId
) {
clearTimeout(pollTimer)
client.removeListener('notification', onNotify)
resolve()
}
} catch {
// ignore parse errors
}
}
client.on('notification', onNotify)
})
// Out of the inner promise — caller will retry tryClaimJob.
return
}
} finally {
await client.end().catch(() => {})
}
}
// ----- main -----------------------------------------------------------
async function main(): Promise<number> {
log('claim attempt starting')
const { userId, tokenId } = await getAuth()
log(`auth ok user_id=${userId} token_id=${tokenId}`)
// Worker presence — UI leest claude_workers.last_seen_at.
// UPSERT zodat de rij tussen iteraties blijft bestaan (geen flicker),
// en heartbeat houdt last_seen_at vers tijdens quota-backoff,
// LISTEN-wait, claude-spawn, en cleanup. Niet unregisteren bij exit:
// de UI prunet zelf rijen ouder dan 60s.
try {
await registerWorker({ userId, tokenId })
} catch (err) {
logError(`registerWorker failed (non-fatal): ${(err as Error).message}`)
}
const workerHeartbeat = startHeartbeat({
userId,
tokenId,
intervalMs: WORKER_HEARTBEAT_INTERVAL_MS,
})
try {
// 1. Quota probe (gate vóór elke claim).
try {
await quotaProbe(userId)
} catch (err) {
logError(`quota probe error: ${(err as Error).message}`)
return 1
}
// 2. Reset stale claims, then attempt to claim.
await resetStaleClaimedJobs(userId)
let jobId = await tryClaimJob(userId, tokenId)
if (!jobId) {
log(`no job claimed — LISTEN scrum4me_changes deadline=${WAIT_DEADLINE_SECONDS}s`)
await waitForEnqueue(userId)
await resetStaleClaimedJobs(userId)
jobId = await tryClaimJob(userId, tokenId)
}
if (!jobId) {
log(`claim timeout after ${WAIT_DEADLINE_SECONDS}s — exiting 0`)
return 0
}
log(`claimed job_id=${jobId}`)
// Per-job log: symlink jobs/<jobId>.log -> the runs/<timestamp>.log of
// this iteration. runs/ files are timestamp-named, so without this a job's
// output is only findable by grepping. run-agent.sh passes the run-log
// path via RUN_LOG. Relative target so it survives the host bind-mount.
// Best-effort — never fail the job over a log convenience. Dangling links
// (after the runs/ file is gzipped/deleted) are pruned by log-cleanup.sh.
const runLog = process.env.RUN_LOG
if (runLog) {
try {
const jobsDir = join(process.env.AGENT_LOG_DIR ?? '/var/log/agent', 'jobs')
mkdirSync(jobsDir, { recursive: true })
const linkPath = join(jobsDir, `${jobId}.log`)
rmSync(linkPath, { force: true })
symlinkSync(join('..', 'runs', basename(runLog)), linkPath)
} catch (err) {
log(`per-job log symlink skipped for ${jobId}: ${(err as Error).message}`)
}
}
// 3. Resolve full context.
let ctx: Awaited<ReturnType<typeof getFullJobContext>> = null
try {
ctx = await getFullJobContext(jobId)
} catch (err) {
logError(`getFullJobContext error job_id=${jobId} ${(err as Error).message}`)
log(`rollback claim job_id=${jobId} reason=context_fetch_failed`)
await rollbackClaim(jobId)
return 1
}
if (!ctx) {
logError(`getFullJobContext returned null for job_id=${jobId}`)
await rollbackClaim(jobId)
return 1
}
// 4. Attach worktree for TASK_IMPLEMENTATION; sprint/idea-jobs hebben hun
// eigen worktree-pad al ingevuld door getFullJobContext.
// We werken hier met `any` omdat de return-type van getFullJobContext een
// discriminated union is en TypeScript hier zonder kind-narrow geen velden
// exposed; de runtime checks dekken alle paden af.
const ctxAny = ctx as any
let worktreePath: string | null =
ctxAny.worktree_path ?? ctxAny.primary_worktree_path ?? null
if (ctx.kind === 'TASK_IMPLEMENTATION') {
if (!ctxAny.story || !ctxAny.task) {
logError(`TASK_IMPLEMENTATION job has incomplete story/task context`)
await rollbackClaim(jobId)
await releaseLocksOnTerminal(jobId)
return 1
}
const wt = await attachWorktreeToJob(
ctxAny.product.id,
jobId,
ctxAny.story.id,
ctxAny.task.repo_url,
)
if ('error' in wt) {
logError(`attachWorktreeToJob error job_id=${jobId} ${wt.error}`)
log(`rollback claim job_id=${jobId} reason=worktree_attach_failed`)
await rollbackClaim(jobId)
await releaseLocksOnTerminal(jobId)
return 1
}
worktreePath = wt.worktree_path
ctxAny.worktree_path = wt.worktree_path
ctxAny.branch_name = wt.branch_name
log(`worktree path=${wt.worktree_path} branch=${wt.branch_name}`)
} else if (worktreePath) {
log(`worktree path=${worktreePath} (pre-resolved)`)
}
// 5. Resolved config — log voor audit.
const cfg = ctx.config
const effort = mapBudgetToEffort(cfg.thinking_budget)
log(
`config job_id=${jobId} model=${cfg.model} ` +
`permission_mode=${cfg.permission_mode} ` +
`thinking_budget=${cfg.thinking_budget} effort=${effort ?? '-'} ` +
`max_turns=${cfg.max_turns ?? 'null'} ` +
`allowed_tools_count=${cfg.allowed_tools?.length ?? 0}`,
)
// 6. Write payload to /tmp/job-<id>/payload.json.
const payloadDir = `/tmp/job-${jobId}`
const payloadPath = `${payloadDir}/payload.json`
mkdirSync(payloadDir, { recursive: true })
const payloadJson = JSON.stringify(ctx, null, 2)
writeFileSync(payloadPath, payloadJson, 'utf8')
log(
`payload written path=${payloadPath} size_bytes=${Buffer.byteLength(payloadJson)}`,
)
// 7. Build CLI args.
const promptText = getKindPromptText(ctx.kind).replace('$PAYLOAD_PATH', payloadPath)
// --output-format is configureerbaar via env. Default 'stream-json' geeft
// de volledige event-stream (elke tool-call, elk bericht) live in de
// run-log, i.p.v. alleen Claude's eind-samenvatting. stream-json vereist
// --verbose in print-mode. Zet AGENT_CLAUDE_OUTPUT_FORMAT=text terug voor
// de oude terse output. TOKEN_EXPIRED-detectie werkt ongewijzigd: de
// auth-error-strings staan ook binnen de JSON-events.
const outputFormat = process.env.AGENT_CLAUDE_OUTPUT_FORMAT ?? 'stream-json'
const args: string[] = [
'-p',
promptText,
'--model',
cfg.model,
'--permission-mode',
cfg.permission_mode,
'--allowedTools',
(cfg.allowed_tools ?? []).join(','),
'--mcp-config',
MCP_CONFIG,
'--add-dir',
'/opt/agent',
'--output-format',
outputFormat,
]
if (outputFormat === 'stream-json') args.push('--verbose')
if (effort) args.push('--effort', effort)
const cwd = worktreePath ?? '/opt/agent'
// Log args zonder de volledige prompt-tekst (kan kilo's groot zijn).
const argsForLog = args
.map((a, i) => (i === 1 ? `<prompt-${promptText.length}-chars>` : a))
.join(' ')
log(`spawn claude job_id=${jobId} cwd=${cwd} args="${argsForLog}"`)
// 8. Lease-renewal heartbeat for SPRINT_IMPLEMENTATION.
let heartbeatTimer: NodeJS.Timeout | null = null
if (ctx.kind === 'SPRINT_IMPLEMENTATION') {
heartbeatTimer = setInterval(() => {
prisma
.$executeRaw`UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId}`
.then(() => {
log(
`heartbeat tick job_id=${jobId} lease_until=${new Date(
Date.now() + 5 * 60_000,
).toISOString()}`,
)
})
.catch((err: Error) => {
logError(`heartbeat error: ${err.message}`)
})
}, HEARTBEAT_INTERVAL_MS)
}
// 9. Spawn Claude.
const start = Date.now()
let exitCode: number | null = null
let stdoutBuf = ''
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('claude', args, { cwd })
child.stdout.on('data', (chunk) => {
const s = chunk.toString()
process.stdout.write(s)
stdoutBuf += s
})
child.stderr.on('data', (chunk) => {
const s = chunk.toString()
process.stderr.write(s)
stdoutBuf += s
})
child.on('error', (err) => reject(err))
child.on('close', (code) => {
exitCode = code
resolve()
})
})
} catch (err) {
logError(`spawn error: ${(err as Error).message}`)
if (heartbeatTimer) clearInterval(heartbeatTimer)
await rollbackClaim(jobId).catch(() => {})
await releaseLocksOnTerminal(jobId).catch(() => {})
return 1
} finally {
if (heartbeatTimer) clearInterval(heartbeatTimer)
}
const durationMs = Date.now() - start
log(
`claude done job_id=${jobId} exit_code=${exitCode ?? 'null'} ` +
`duration_ms=${durationMs} wall_clock_seconds=${Math.round(durationMs / 1000)}`,
)
// 10. Token-expiry detection — alleen als Claude zelf non-zero eindigde.
// stdoutBuf bevat de volledige stream-json output incl. álle tool-results,
// dus de auth-error-strings kunnen ook agent-werk-content zijn (een doc
// over 401-handling gelezen, een endpoint getest). Een echte credential-
// fout laat 'claude' non-zero exiten; een geslaagde run (exit 0) is per
// definitie geen token-expiry. Zonder deze gate legt zulke content de
// worker onterecht plat (run-agent.sh → TOKEN_EXPIRED marker + sleep).
let tokenExpired = false
if (exitCode !== 0) {
for (const pat of TOKEN_EXPIRY_PATTERNS) {
if (pat.test(stdoutBuf)) {
tokenExpired = true
log(`TOKEN_EXPIRED detected pattern="${pat.source}" exiting code=3`)
break
}
}
}
// 11. Rollback claim if Claude exited non-zero without updating job-status.
// PBI-50 lease-driven recovery vangt resterende stale jobs op na 5 min.
if (exitCode !== 0 && !tokenExpired) {
const jobNow = await prisma.claudeJob.findUnique({
where: { id: jobId },
select: { status: true },
})
if (jobNow?.status === 'CLAIMED' || jobNow?.status === 'RUNNING') {
log(
`rollback claim job_id=${jobId} reason=claude_exit_${exitCode}_without_status_update`,
)
await rollbackClaim(jobId).catch(() => {})
await releaseLocksOnTerminal(jobId).catch(() => {})
}
}
// 12. Cleanup payload directory.
try {
rmSync(payloadDir, { recursive: true, force: true })
} catch {
// non-fatal
}
log(`cleanup payload_removed=true heartbeat_stopped=${heartbeatTimer !== null}`)
if (tokenExpired) return 3
return exitCode ?? 1
} finally {
workerHeartbeat.stop()
}
}
// ----- entry ----------------------------------------------------------
process.on('SIGTERM', () => {
prisma.$disconnect().finally(() => process.exit(143))
})
main()
.then(async (code) => {
log(`exit code=${code}`)
await prisma.$disconnect().catch(() => {})
process.exit(code)
})
.catch(async (err) => {
logError(`fatal: ${(err as Error).stack ?? err}`)
await prisma.$disconnect().catch(() => {})
process.exit(1)
})

View file

@ -1,70 +0,0 @@
#!/usr/bin/env bash
# worker-quota-probe.sh — pre-flight Anthropic rate-limit-quota meting.
#
# Doet een lichtgewicht /v1/messages-call (1 token max) en parsed de
# `anthropic-ratelimit-*-tokens`-headers. Output is JSON op stdout.
#
# Output (success):
# {"remaining": 9982000, "limit": 10000000, "pct": 99, "reset_at_iso": "..."}
#
# Output (fail):
# {"error": "...", "http_status": NNN}
#
# Exit-codes: 0 success, 1 op fout. De worker-loop gebruikt de pct + reset
# om te beslissen of-ie wait_for_job mag aanroepen (gate via min_quota_pct
# uit get_worker_settings).
#
# Gebruikt env-var ANTHROPIC_API_KEY of CLAUDE_CODE_OAUTH_TOKEN. OAuth-tokens
# worden door de Anthropic API herkend als Bearer; de header-set is dezelfde.
set -uo pipefail
KEY="${ANTHROPIC_API_KEY:-${CLAUDE_CODE_OAUTH_TOKEN:-}}"
if [[ -z "$KEY" ]]; then
echo '{"error":"no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN set"}'
exit 1
fi
HDR_FILE=$(mktemp /tmp/quota-probe.XXXXXX.headers)
trap 'rm -f "$HDR_FILE"' EXIT
# Minimale call: claude-haiku-4-5 met 1 max_tokens en een single-char input.
# Kost ~1 outputtoken; doel is alleen de rate-limit-headers binnen te halen.
HTTP_STATUS=$(curl -sS -o /dev/null \
-D "$HDR_FILE" \
-w '%{http_code}' \
-H "Authorization: Bearer ${KEY}" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"."}]}' \
https://api.anthropic.com/v1/messages 2>/dev/null || echo "000")
# Parse rate-limit headers (case-insensitive grep).
REMAINING=$(grep -i '^anthropic-ratelimit-output-tokens-remaining:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
LIMIT=$(grep -i '^anthropic-ratelimit-output-tokens-limit:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
RESET=$(grep -i '^anthropic-ratelimit-output-tokens-reset:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
# Fallback: sommige plans gebruiken `requests` ipv `tokens` voor de hoofdgrens.
if [[ -z "$REMAINING" ]]; then
REMAINING=$(grep -i '^anthropic-ratelimit-requests-remaining:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
LIMIT=$(grep -i '^anthropic-ratelimit-requests-limit:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
RESET=$(grep -i '^anthropic-ratelimit-requests-reset:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
fi
if [[ -z "$REMAINING" || -z "$LIMIT" ]]; then
printf '{"error":"no rate-limit headers in response","http_status":%s}\n' "$HTTP_STATUS"
exit 1
fi
# Pct als integer (rounded). Bij limit=0 zou je delen door nul — bescherm.
if [[ "$LIMIT" == "0" ]]; then
PCT=0
else
PCT=$(awk -v r="$REMAINING" -v l="$LIMIT" 'BEGIN { printf("%d", (r/l)*100) }')
fi
# Reset-time is al ISO-8601 in de header bij Anthropic; geef ongewijzigd door.
RESET_ESCAPED="${RESET:-}"
printf '{"remaining":%s,"limit":%s,"pct":%s,"reset_at_iso":"%s","http_status":%s}\n' \
"$REMAINING" "$LIMIT" "$PCT" "$RESET_ESCAPED" "$HTTP_STATUS"

View file

@ -25,22 +25,20 @@ services:
- /tmp:size=4g,mode=1777 - /tmp:size=4g,mode=1777
ports: ports:
- "${AGENT_HEALTH_PORT_HOST:-18080}:8080" - "${AGENT_HEALTH_PORT_HOST:-8080}:8080"
restart: unless-stopped restart: unless-stopped
# Apple M2 host (8 cores, ≥16 GB unified). Geef de agent royaal de # N5095 heeft 4 cores. Geef agent er 3, laat 1 voor QTS.
# ruimte voor parallelle npm/tsx/git-werk; macOS + Docker Desktop # Memory: 4GB is ruim voor één Claude Code sessie + één npm install.
# gebruiken zelf ook merkbaar geheugen, dus laat 4-8 GB over.
# Pas omlaag aan voor M2 base met 8 GB of omhoog voor M2 Pro/Max.
deploy: deploy:
resources: resources:
limits: limits:
cpus: "6.0" cpus: "3.0"
memory: 8g memory: 4g
reservations: reservations:
cpus: "1.0" cpus: "0.5"
memory: 1g memory: 512m
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]

View file

@ -1,6 +1,6 @@
{ {
"name": "scrum4me-agent-runner", "name": "scrum4me-agent-runner",
"version": "0.2.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS", "description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS",
"scripts": { "scripts": {
@ -9,7 +9,7 @@
"down": "docker compose down", "down": "docker compose down",
"logs": "docker compose logs -f", "logs": "docker compose logs -f",
"rebuild": "docker compose build --no-cache && docker compose up -d", "rebuild": "docker compose build --no-cache && docker compose up -d",
"health": "curl -fsS http://localhost:${AGENT_HEALTH_PORT_HOST:-18080}/health | jq ." "health": "curl -fsS http://localhost:${AGENT_HEALTH_PORT_HOST:-8080}/health | jq ."
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"