diff --git a/.env.deploy.example b/.env.deploy.example deleted file mode 100644 index 814f018..0000000 --- a/.env.deploy.example +++ /dev/null @@ -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 diff --git a/.env.example b/.env.example index d35bff2..3f3a523 100644 --- a/.env.example +++ b/.env.example @@ -22,29 +22,27 @@ CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-vervang-mij # Als deze ge-revoked wordt: rebuild + redeploy (zie README). SCRUM4ME_TOKEN=vervang-mij -# ----- Forgejo credentials (PBI-86 hybride model) ----------- -# Personal Access Token van je Forgejo-account met scope read+write -# op de Scrum4Me-repos. Variabele heet historisch nog `GH_TOKEN`; -# in het hybride model bevat 'ie een Forgejo-PAT. +# ----- GitHub credentials ----------------------------------- +# Personal Access Token (fine-grained) met: +# - Repository access op madhura68/Scrum4Me + madhura68/scrum4me-mcp +# - Permissions: Contents (read/write), Pull requests (read/write), +# Metadata (read) # # Gebruikt voor: # 1. Pre-clone van de repos in /home/agent/Projects// bij # 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); -# de GitHub-PR ontstaat via de handmatig getriggerde promote-Action -# in Forgejo. -# -# Genereer in Forgejo: avatar → Settings → Applications → -# Generate New Token; scope minimaal `write:repository`. -GH_TOKEN=vervang-mij +# Genereer op github.com → Settings → Developer settings → +# Personal access tokens → Fine-grained tokens. +GH_TOKEN=ghp_vervang-mij # Lijst (komma-gescheiden) van repos om vooraf te clonen naar # ~agent/Projects/. resolveRepoRoot in scrum4me-mcp valt -# automatisch terug op die conventie. `/` zoals 'ie op -# Forgejo staat. Voeg meer toe als je nieuwe producten/repos toevoegt. -GH_PRECLONE_REPOS=janpeter/Scrum4Me,janpeter/scrum4me-mcp +# automatisch terug op die conventie. Voeg meer toe als je nieuwe +# producten/repos toevoegt aan Scrum4Me. +GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp # ----- Git commit-author ------------------------------------- # Verplicht — Vercel weigert deploys waarvan de commit-author email @@ -110,9 +108,3 @@ AGENT_BACKOFF_MAX=300 AGENT_LOG_GZIP_AFTER_HOURS=24 # Hoeveel dagen ge-gzipte logs bewaren voor we ze verwijderen. 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 diff --git a/.gitignore b/.gitignore index 8b64b2c..3f91b6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Secrets .env -.env.deploy *.env.local # Local dev overrides (niet committen, per ontwikkelaar) diff --git a/CLAUDE.md b/CLAUDE.md index dea1f98..9cbfacc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,85 +1,89 @@ # CLAUDE.md — Scrum4Me NAS-runner -Je draait als headless worker op een QNAP NAS (of lokale Docker). Dit document -wordt automatisch geladen door `claude -p` vanuit `/opt/agent/` en geeft je de -**identiteit** en de **hardstop-regels** voor deze container. De per-job -**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//`). -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. +Je draait als headless worker op een QNAP NAS. Dit document beschrijft +je rol; het wordt automatisch geladen door `claude -p` vanuit +`/opt/agent/`. ## Identiteit - Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id` - tonen jouw token. -- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT) - of de `primary_worktree_path` (idea-jobs). Buiten die directory en - `/var/log/agent` heb je niets te zoeken. -- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')` - aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`). + zullen jouw token tonen. +- Je hebt **geen push-rechten**. Geen SSH-keys op deze container, geen + `~/.gitconfig` met push-credentials. Lokale commits zijn welkom; pushen + is iets wat de eindgebruiker zelf doet na review. +- Je opereert binnen `/tmp/job-` per job. Buiten die directory en + 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 - je geclaimd. Eén invocation = één job. -- **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. +Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"* +of equivalent: -## Project-CLAUDE.md (in worktree) - -De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude -automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de -Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die -bevat de ST-code-commit-stijl, lint/test/build-commands, en project- -specifieke patronen. +1. Roep `mcp__scrum4me__wait_for_job` aan. Geen argumenten, geen wait-time + tweaken — de tool blokt zelf tot 600 s. +2. Als er een job geclaimd wordt: + 1. Roep `bash /opt/agent/bin/job-prepare.sh ` aan + via Bash. Output is het pad van de working tree. + 2. `cd` naar dat pad. + 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 ` 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 -- **Verificatie faalt** (lint/test/build rood): roep - `update_job_status('failed', error: )` aan en sluit af. Geen - automatische fix-attempts; de eindgebruiker beslist. -- **Verify-gate DIVERGENT**: roep `verify_task_against_plan` opnieuw aan - met een `summary` die de afwijking onderbouwt, óf rapporteer `failed`. -- **Onverwachte runtime-fout**: laat de exception propageren. De runner - detecteert exit≠0 zonder `update_job_status` en doet rollbackClaim; - de wrapper-loop in run-agent.sh schrijft een run-log en herstart met - backoff. +- **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer + `update_job_status('failed', error=...)` en ga door met de volgende job. + Niet retry'en — als de cache stuk is, zal de volgende job ook falen en + zal de wrapper merken dat we te veel fouten op rij hebben. +- **Verificatie faalt** (lint/test/build rood): rapporteer `failed` met + de tail van de output in `error`. Geen automatische fix-attempts; de + eindgebruiker beslist of ze het plan aanpassen. +- **Onverwachte runtime-fout** in de tools: laat de exception propageren. + De wrapper-loop schrijft een run-log en herstart `claude -p` met backoff. ## Vraag-antwoord-kanaal (M11) -Voor blokkerende keuzes die niet uit het plan volgen: gebruik -`mcp__scrum4me__ask_user_question` met 2–4 `options` en `wait_seconds: 600`. -Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord -op vraag ")`. Niet gokken. Niet aannemen. +Als het `implementation_plan` ambigu is op een keuze die niet uit de +acceptance-criteria volgt: gebruik `mcp__scrum4me__ask_user_question` +met een korte vraag plus 2–4 `options`. Geef `wait_seconds: 600` mee +zodat de tool blijft wachten. Als de timer afloopt zonder antwoord: +status `failed`, `error: "Wacht op gebruikersantwoord op vraag "`, +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 - (komt uit `scrum4me-mcp/src/prompts//`). -- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo. -- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo. +## Wat je NIET doet + +- Geen `git push`, ook niet naar `origin/` van een feature-branch. +- 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. diff --git a/Dockerfile b/Dockerfile index 683202d..e01b8fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. # De vendor/scrum4me submodule is alleen nodig om het schema te updaten, # 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 -# 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}" \ - && git clone --branch ${MCP_GIT_REF} --depth 1 \ +RUN git clone --branch ${MCP_GIT_REF} --depth 1 \ ${MCP_GIT_REPO} /opt/scrum4me-mcp \ && cd /opt/scrum4me-mcp \ && 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 -# ----- 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 ---------------------------------------------------- ENV PATH=/opt/agent/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ 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_REPO_CACHE=/var/cache/repos \ AGENT_JOB_ROOT=/tmp \ - AGENT_HEALTH_PORT=8080 \ - SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp \ - NODE_PATH=/opt/scrum4me-mcp/node_modules + AGENT_HEALTH_PORT=8080 EXPOSE 8080 diff --git a/README.md b/README.md index 0f9cd3d..5f09298 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ nooit zelf. │ │ │ ┌─ container: agent-runner ────────────────────────────────┐ │ │ │ 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) │ │ │ │ └─ scrum4me-mcp → Neon Postgres │ │ │ │ │ │ @@ -52,30 +52,16 @@ fouten. | `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/health-server.js` | HTTP-endpoint op 8080 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`, `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@ '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`. +- Drie shares aangemaakt: `/share/Agent/cache`, `/share/Agent/logs`, `/share/Agent/state` +- Of één share `/share/Agent` waaronder de drie subdirs vallen +- Internet-uitgang naar `api.anthropic.com`, `github.com`, je Neon-host, `registry.npmjs.org` ## Deploy @@ -85,21 +71,18 @@ fouten. # 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 → Forgejo → avatar → Settings → -# Applications → Generate New Token; scope -# minimaal `write:repository` op de twee -# repos (janpeter/Scrum4Me + janpeter/ -# scrum4me-mcp). Wordt gebruikt voor clone -# en push naar Forgejo. PBI-86 (hybride -# model): `gh pr create` is uit de -# worker-flow verwijderd — de GitHub-PR -# komt via de handmatige promote-Action -# in Forgejo. +# 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://git.jp-visser.nl//scrum4me-agent-runner.git +git clone https://github.com//scrum4me-agent-runner.git cd scrum4me-agent-runner # 3. Env aanmaken @@ -112,158 +95,10 @@ docker compose build docker compose up -d # 5. Verifiëren -curl http://nas.local:18080/health +curl http://nas.local:8080/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: @@ -301,7 +136,7 @@ docker exec scrum4me-agent df -h /var/cache ## Health-endpoint -`GET http://:18080/health` retourneert: +`GET http://:8080/health` retourneert: ```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: 1. Configureert git's credential-helper met `GH_TOKEN` zodat - `git clone`/`push` naar `https://git.jp-visser.nl/...` (Forgejo) zonder - prompt werkt. + `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` @@ -341,118 +175,8 @@ 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` is in PBI-86 (T-1005) verwijderd -uit de worker-flow** — de GitHub-PR ontstaat via een handmatig -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@ " - 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. +slaagt zonder prompt. `gh pr create` (voor producten met `auto_pr=true`) +gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect. ## Bekende grenzen diff --git a/bin/deploy-to-nas.sh b/bin/deploy-to-nas.sh deleted file mode 100755 index 409c9c3..0000000 --- a/bin/deploy-to-nas.sh +++ /dev/null @@ -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" diff --git a/bin/job-prepare.sh b/bin/job-prepare.sh index f3ec175..96f54c4 100644 --- a/bin/job-prepare.sh +++ b/bin/job-prepare.sh @@ -28,7 +28,7 @@ if [[ -z "$JOB_ID" || -z "$REPO_URL" ]]; then exit 2 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" \ | sed -E 's#^.*[:/]([^/]+/[^/]+?)(\.git)?/?$#\1#' \ | tr '/' '_') diff --git a/bin/log-cleanup.sh b/bin/log-cleanup.sh index 5a4ad85..dbd8a0d 100755 --- a/bin/log-cleanup.sh +++ b/bin/log-cleanup.sh @@ -18,9 +18,4 @@ find "${AGENT_LOG_DIR}" -type f \ \( -name '*.log' -o -name '*.log.gz' -o -name '*.txt' -o -name '*.json' \) \ -mtime "+${AGENT_LOG_HARD_DELETE_DAYS}" -delete 2>/dev/null || true -# Prune dangling per-job symlinks: jobs/.log -> runs/.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 diff --git a/bin/repo-bootstrap.sh b/bin/repo-bootstrap.sh index 7f71c53..850c42f 100644 --- a/bin/repo-bootstrap.sh +++ b/bin/repo-bootstrap.sh @@ -32,8 +32,8 @@ fi mkdir -p "$HOME" git config --global credential.helper store CREDS_FILE="$HOME/.git-credentials" -if [[ ! -f "$CREDS_FILE" ]] || ! grep -q "oauth2:${GH_TOKEN}@git.jp-visser.nl" "$CREDS_FILE" 2>/dev/null; then - printf 'https://oauth2:%s@git.jp-visser.nl\n' "$GH_TOKEN" > "$CREDS_FILE" +if [[ ! -f "$CREDS_FILE" ]] || ! grep -q "oauth2:${GH_TOKEN}@github.com" "$CREDS_FILE" 2>/dev/null; then + printf 'https://oauth2:%s@github.com\n' "$GH_TOKEN" > "$CREDS_FILE" chmod 600 "$CREDS_FILE" log "git credentials helper configured at ${CREDS_FILE}" fi @@ -71,7 +71,7 @@ for repo in "${REPOS[@]}"; do else log "cloning ${repo} into ${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; } fi done diff --git a/bin/run-agent.sh b/bin/run-agent.sh index 6b3dd32..d392c9c 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -3,19 +3,14 @@ # # Strategie: # - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal) -# - Loop: tsx /opt/agent/bin/run-one-job.ts (één geclaimde job per iteratie) -# - Exit 0 → de queue was leeg of de job is afgerond, sleep kort, herhaal -# - Exit 3 → run-one-job detecteerde TOKEN_EXPIRED in Claude-output +# - Loop: claude -p met seed-prompt +# - Exit 0 → de queue was leeg, sleep kort, herhaal # - Exit ≠ 0 → exponential backoff, log, schrijf state, herhaal # - Bij N opeenvolgende fouten → schrijf UNHEALTHY marker; health # endpoint gaat op 503, container blijft runnen voor diagnose # - Bij gedetecteerde token-expiry → schrijf TOKEN_EXPIRED marker # en exit (compose start opnieuw, maar entrypoint zal dezelfde # 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 @@ -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/log-cleanup.sh || true -# Geen seed-prompt en geen ALLOWED_TOOLS-string meer: per-job CLI-flags -# (incl. --model, --permission-mode, --effort, --allowedTools en de -# kind-specifieke prompt) worden door run-one-job.ts gebouwd uit -# JobConfig (resolved via PBI-67's resolveJobConfig). +# ----- seed prompt ------------------------------------------------------ +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.' + +# 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 BACKOFF=${AGENT_BACKOFF_START} @@ -62,31 +60,32 @@ while true; do --argjson failures "$CONSEC_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 - # claimt zelf via tryClaimJob, leest JobConfig (PBI-67), bouwt de - # juiste Claude CLI-args, spawnt 'claude', wacht, sluit af. + # claude -p met onze MCP-config en allowlist. + # cwd = /opt/agent zodat onze CLAUDE.md auto-geladen wordt. + # + # --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 - # RUN_LOG laat run-one-job.ts een jobs/.log symlink leggen naar - # dit run-log, zodat de output van een job op job-id vindbaar is. - RUN_LOG="${run_log}" tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 + claude -p "${SEED_PROMPT}" \ + --mcp-config /opt/agent/mcp-config.json \ + --allowedTools "${ALLOWED_TOOLS}" \ + --permission-mode bypassPermissions \ + --output-format text \ + > "${run_log}" 2>&1 exit_code=$? set -e iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ) log "batch ended exit=${exit_code}" - # Token-expiry detectie: run-one-job.ts retourneert exit 3 wanneer het - # bekende auth-error-strings in Claude's output ziet. We checken óók de - # log-tekst voor het geval een ander pad het patroon raakt (bv. Prisma- - # 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" + # Token-expiry detectie: parse stderr/stdout op bekende strings. + if grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then + log "AUTH FAILURE detected in run log — marking TOKEN_EXPIRED" touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED" write_state "$(jq -n \ --arg endedAt "$iteration_end" \ diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts deleted file mode 100644 index f9cc879..0000000 --- a/bin/run-one-job.ts +++ /dev/null @@ -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-/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 { - 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 { - 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((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 { - 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/.log -> the runs/.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> = 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-/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 ? `` : 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((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) - }) diff --git a/bin/worker-quota-probe.sh b/bin/worker-quota-probe.sh deleted file mode 100755 index 908f33f..0000000 --- a/bin/worker-quota-probe.sh +++ /dev/null @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index e00552d..ac31ce1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,22 +25,20 @@ services: - /tmp:size=4g,mode=1777 ports: - - "${AGENT_HEALTH_PORT_HOST:-18080}:8080" + - "${AGENT_HEALTH_PORT_HOST:-8080}:8080" restart: unless-stopped - # Apple M2 host (8 cores, ≥16 GB unified). Geef de agent royaal de - # ruimte voor parallelle npm/tsx/git-werk; macOS + Docker Desktop - # 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. + # N5095 heeft 4 cores. Geef agent er 3, laat 1 voor QTS. + # Memory: 4GB is ruim voor één Claude Code sessie + één npm install. deploy: resources: limits: - cpus: "6.0" - memory: 8g + cpus: "3.0" + memory: 4g reservations: - cpus: "1.0" - memory: 1g + cpus: "0.5" + memory: 512m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] diff --git a/package.json b/package.json index b5bc4cb..ee24c53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-agent-runner", - "version": "0.2.0", + "version": "0.1.0", "private": true, "description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS", "scripts": { @@ -9,7 +9,7 @@ "down": "docker compose down", "logs": "docker compose logs -f", "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": { "node": ">=22"