From 3f10d92ece9d2ab322ba04c7bf02b0151a6daf47 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 16:20:47 +0200 Subject: [PATCH 01/16] =?UTF-8?q?chore:=20bump=20to=20v0.2.0=20=E2=80=94?= =?UTF-8?q?=20rebuild=20voor=20entity-codes-required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrum4me-mcp main bevat nu auto-generatie van PBI/Story/Task codes (PBI-N, ST-001, T-N). Rebuild triggert zodat de NAS-runner de bijgewerkte MCP-server oppikt. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee24c53..3121c20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-agent-runner", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS", "scripts": { From 6fb439cbd6e793837c9f0dcd2896ff70c7f49da0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 5 May 2026 19:55:22 +0200 Subject: [PATCH 02/16] fix(qnap): default host-port 18080 + documenteer cross-build deploy - docker-compose.yml + package.json: AGENT_HEALTH_PORT_HOST default van 8080 -> 18080. QTS-webinterface bezet host-poort 8080; eerdere default veroorzaakte een Container-Station retry-loop die de QTS-UI onderuit haalde. Container-interne 8080 blijft ongewijzigd. - README.md: drie nieuwe / uitgebreide secties op basis van een echte deploy-sessie op TS-664: * "Vereisten op de NAS" verduidelijkt dat /share/Agent een echte QTS Shared Folder moet zijn (geen handmatige mkdir op de 16 MB /share-tmpfs). * "Deploy - cross-build vanaf Mac" beschrijft de Apple Silicon -> amd64 flow met buildx, docker save | gzip, scp en docker load. * "Veelvoorkomende issues" verzamelt de gotchas: tmpfs-share, docker not on PATH bij non-interactive ssh (source /etc/profile), yaml control-character corruption na halve scp, en .env-loss na rm -rf van een corrupt-tmpfs-share. --- README.md | 256 +++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 2 +- package.json | 2 +- 3 files changed, 251 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5f09298..046bf98 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) │ │ +│ │ ├─ health-server.js (8080 → host 18080) │ │ │ │ └─ claude -p (per-batch, met MCP via stdio) │ │ │ │ └─ scrum4me-mcp → Neon Postgres │ │ │ │ │ │ @@ -52,16 +52,30 @@ 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 dat state.json en marker-files leest | +| `bin/health-server.js` | HTTP-endpoint op 8080 (intern) dat state.json en marker-files leest | | `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden | | `.env.example` | Alle env-vars met uitleg | ## Vereisten op de NAS - Container Station 2+ (Docker compose v2) -- 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` +- **`Agent` als QTS Shared Folder** op een echte volume (bv. `CACHEDEV1_DATA`). + Niet een `mkdir /share/Agent` — `/share` zelf is een 16 MB tmpfs en handmatige + directories overleven geen reboot. Aanmaken via Control Panel → Privilege → + Shared Folders → Create. QTS legt dan automatisch de symlink + `/share/Agent → /share/CACHEDEV1_DATA/Agent`. +- Drie subdirs onder die share: `/share/Agent/cache`, `/share/Agent/logs`, + `/share/Agent/state`. Aanmaken via File Station of via SSH na share-creatie. +- Internet-uitgang naar `api.anthropic.com`, `github.com`, je Neon-host, `registry.npmjs.org`. + +> **Verifieer** vóór je deployt dat `/share/Agent` echt op disk staat: +> ```bash +> ssh admin@ 'ls -la /share/ | grep Agent; df -h /share/Agent' +> ``` +> Verwacht een symlink (`l...Agent -> /share/CACHEDEV1_DATA/Agent`) en een +> df-uitvoer met TB-grootte op `cachedev1`/`cachedev2`. Als je hier `tmpfs 16M` +> ziet, is de share geen geregistreerde QTS Shared Folder en zal elke transfer +> >16 MB falen met `scp: write remote ... Failure`. ## Deploy @@ -95,10 +109,130 @@ docker compose build docker compose up -d # 5. Verifiëren -curl http://nas.local:8080/health +curl http://nas.local:18080/health docker compose logs -f ``` +> **QNAP-port:** host-poort 8080 is bezet door de QTS-webinterface; daarom +> mapt deze stack standaard `18080:8080`. Override via +> `AGENT_HEALTH_PORT_HOST` in `.env` als je een andere host-poort wilt. + +## 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: @@ -136,7 +270,7 @@ docker exec scrum4me-agent df -h /var/cache ## Health-endpoint -`GET http://:8080/health` retourneert: +`GET http://:18080/health` retourneert: ```json { @@ -178,6 +312,114 @@ Push gaat over dezelfde token: `git push -u origin feat/story-` slaagt zonder prompt. `gh pr create` (voor producten met `auto_pr=true`) gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect. +## Veelvoorkomende issues + +Verzameld uit echte deploy-sessies op een TS-664 met QTS 5.x. Eerst checken +voor je gaat tweaken. + +### `/share/Agent` is `tmpfs 16M` — scp faalt op grote files + +**Symptoom:** +``` +scp: write remote ".../scrum4me-agent-runner-amd64.tar.gz": Failure +``` +Eerst 100 % bij kleine files, dan blijvend "Failure" zodra een file de tmpfs +vol maakt. + +**Oorzaak:** `/share/Agent` is geen geregistreerde QTS Shared Folder maar een +gewone directory in de root-tmpfs. Na elke reboot is alle inhoud weg en is +`/share/Agent` slechts een entry in de 16 MB `/share`-tmpfs. + +**Fix:** maak `Agent` aan via Control Panel → Privilege → Shared Folders → +Create. Daarna verschijnt automatisch `lrwxrwxrwx /share/Agent → /share/CACHEDEV1_DATA/Agent`. +Als die symlink ontbreekt omdat er al een tmpfs-directory `/share/Agent` staat: + +```bash +ssh admin@ " + source /etc/profile + docker stop scrum4me-agent 2>/dev/null + docker rm scrum4me-agent 2>/dev/null + rm -rf /share/Agent + ln -s /share/CACHEDEV1_DATA/Agent /share/Agent + mkdir -p /share/Agent/{cache,logs,state,scrum4me-agent-runner} + ls -la /share/ | grep Agent # moet nu een symlink tonen +" +``` + +> **`rm -rf /share/Agent` is veilig** zolang `/share/Agent` nog tmpfs is — +> alle content stond in RAM en zou de volgende reboot toch verdwenen zijn. +> Maar verifieer eerst met `df -h /share/Agent` dat 't echt tmpfs is. + +### `docker: command not found` in non-interactieve ssh + +**Symptoom:** +```bash +ssh admin@ 'docker ps' +# sh: line 1: docker: command not found +``` + +**Oorzaak:** QNAP's `admin`-user heeft `docker` op `$PATH` via +`/etc/profile.d/*.sh` van Container Station. Login-shells laden die scripts; +non-interactieve `ssh user@host 'cmd'` doet dat **niet**. + +**Fix:** `source /etc/profile` aan het begin van je remote command: + +```bash +ssh admin@ "source /etc/profile && docker ps" +``` + +### `yaml: control characters are not allowed` + +**Symptoom:** +```bash +docker compose down +# yaml: control characters are not allowed +``` + +**Oorzaak:** een eerdere `scp` van `docker-compose.yml` faalde halverwege en +liet een file met NUL-bytes / partial writes achter. De file-grootte klopt +maar de inhoud is corrupt. + +**Fix:** scp opnieuw vanaf je werkstation. Voor het stoppen van een al- +draaiende container heb je de yml niet nodig: + +```bash +docker stop scrum4me-agent && docker rm scrum4me-agent +``` + +Daarna `scp docker-compose.yml admin@:/share/Agent/scrum4me-agent-runner/` +en pas dan `docker compose up -d --no-build --force-recreate`. + +### QTS-console-menu Python-traceback bij ssh-commando + +**Symptoom:** lange Python-traceback over `consolemenu_q/prompt_utils.py:31` +en `Inappropriate ioctl for device`, gevolgd door je daadwerkelijke output. + +**Oorzaak:** QTS' admin-shell start een Python-TUI (qts-console-menu) die +crasht zonder echte TTY. Niet-fataal — je commando wordt alsnog gerund. + +**Fix:** negeer de traceback. Als je 'm écht weg wilt, gebruik +`ssh -tt admin@ '...'` voor een geforceerde pseudo-TTY, maar pas op: +sommige scripts hangen daarop omdat ze interactie verwachten. + +### `.env` is weg na `rm -rf /share/Agent` + +**Symptoom:** na het opruimen van een tmpfs-`/share/Agent` weigert +`docker compose up`: +``` +env file /share/Agent/scrum4me-agent-runner/.env not found +``` + +**Oorzaak:** `.env` zit in `.gitignore` en wordt nooit door `git pull` of `scp +.` automatisch teruggezet. Bij het wegnuken van een corrupte share verdwijnt-ie +mee. + +**Fix:** scp 'm vanaf je werkstation, en patch path-velden op de NAS-zijde +(zie [Deploy — cross-build](#deploy--cross-build-vanaf-mac-apple-silicon--amd64-nas) +stap 4). Zorg dat je `.env` op je werkstation **alle** secrets bevat — vooral +`SCRUM4ME_TOKEN`, dat in een Mac-only `.env` makkelijk ontbreekt omdat +lokale tests soms zonder Scrum4Me-API draaien. + ## Bekende grenzen - **Eén actieve job tegelijk.** De wrapper-loop is sequentieel. Voor diff --git a/docker-compose.yml b/docker-compose.yml index ac31ce1..f12b762 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: - /tmp:size=4g,mode=1777 ports: - - "${AGENT_HEALTH_PORT_HOST:-8080}:8080" + - "${AGENT_HEALTH_PORT_HOST:-18080}:8080" restart: unless-stopped diff --git a/package.json b/package.json index ee24c53..3b2c1b3 100644 --- a/package.json +++ b/package.json @@ -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:-8080}/health | jq ." + "health": "curl -fsS http://localhost:${AGENT_HEALTH_PORT_HOST:-18080}/health | jq ." }, "engines": { "node": ">=22" From 266e1f2773621af462d09b19489a0a25160a75ac Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:39:24 +0200 Subject: [PATCH 03/16] docs(claude): worker pusht en maakt PR niet meer zelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflecteer de werkelijkheid: de scrum4me-mcp tool update_job_status ('done') roept pushBranchForJob + maybeCreateAutoPr aan. Auto-merge (squash) wordt door dezelfde tool aangezet (scrum4me-mcp PR #23). CLAUDE.md zei eerder expliciet "geen push-rechten" en "Niet pushen" — dat klopt niet meer als auto_pr=true op het product staat. Worker moet juist NIET handmatig pushen of PR's maken; dat verstoort de pushed_at-tracking en kan branch-conflicts geven. Verwijst naar Scrum4Me docs/runbooks/auto-pr-flow.md voor de volledige keten. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9cbfacc..cadeabc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,18 @@ je rol; het wordt automatisch geladen door `claude -p` vanuit - Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id` 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 hebt **geen handmatige push- of PR-acties nodig.** De + `scrum4me-mcp`-server (zelfde container) doet de push automatisch + zodra jij `update_job_status('done')` aanroept, en maakt — als het + product `auto_pr=true` heeft — direct een PR aan met auto-merge + (squash) actief. Roep dus geen `git push` of `gh pr create` zelf aan; + laat de MCP-laag dat doen. - Je opereert binnen `/tmp/job-` per job. Buiten die directory en buiten `/var/log/agent` heb je niets te zoeken. +Volledige documentatie van de auto-PR-keten: `docs/runbooks/auto-pr-flow.md` +in de Scrum4Me-repo. + ## Operationele loop (verplicht) Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"* @@ -33,11 +39,16 @@ of equivalent: 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. + 6. **Niet zelf pushen of PR's maken.** Lokaal committen op een + feature-branch is goed. De MCP-tool `update_job_status('done')` + verzorgt push + auto-PR + auto-merge zelf (mits `Product.auto_pr=true`). 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. + - Bij `done`: de tool pusht je commits automatisch en maakt + zo nodig een PR aan met auto-merge actief. Verwacht dus dat + de respons `pushed_at` en `pr_url` kan bevatten. 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: @@ -78,8 +89,14 @@ Niet gokken. Niet aannemen. ## 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 handmatige `git push`. De MCP-tool `update_job_status('done')` + pusht zelf via `pushBranchForJob`. Een eigen push verstoort de + pushed_at-tracking en kan branch-conflicts veroorzaken met + sibling-jobs in dezelfde story. +- Geen `gh pr create` of `gh pr merge`. De MCP-tool `maybeCreateAutoPr` + doet dit afhankelijk van `Product.auto_pr`. +- Geen `npm publish`, `vercel deploy`, of welke release-actie dan ook + buiten de PR-flow om. - Geen edits buiten `/tmp/job-*` (geen `~/.bashrc`, geen `/etc/...`, geen andere shares). - Geen credentials uitprinten of in commit-messages stoppen — `.env` From 4b2241235eb462a1a68c8d8cb8a6019b7417409a Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 01:30:22 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat(deploy):=20bin/deploy-to-nas.sh=20vo?= =?UTF-8?q?or=20=C3=A9=C3=A9n-commando=20redeploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lost terugkerende pijn op: na cross-build op Mac vergeet je makkelijk de .env mee te nemen of vanuit de juiste directory te starten, met "FAIL: ... is not set" als gevolg in pre-flight. Script doet in volgorde: 1. docker buildx build --platform linux/amd64 --load 2. docker save | gzip → scrum4me-agent-runner-amd64.tar.gz 3. scp tarball + compose + (eerste keer) .env naar NAS 4. ssh: docker load + sanity-check op .env + compose up --force-recreate 5. ssh: docker compose logs -f (Ctrl-C om te stoppen) Bestaande NAS-.env wordt niet overschreven. Eerste deploy patcht de NAS-paden via sed. Sanity-check faalt expliciet als anthropic-, SCRUM4ME_- of DATABASE_URL-vars ontbreken — ipv stille pre-flight-fail. Config via .env.deploy (zit in .gitignore). Voor eerste deploy en volledige procedure: README "Deploy — cross-build" sectie. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.deploy.example | 16 +++++++ .gitignore | 1 + README.md | 28 +++++++++++ bin/deploy-to-nas.sh | 112 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 .env.deploy.example create mode 100755 bin/deploy-to-nas.sh diff --git a/.env.deploy.example b/.env.deploy.example new file mode 100644 index 0000000..814f018 --- /dev/null +++ b/.env.deploy.example @@ -0,0 +1,16 @@ +# .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/.gitignore b/.gitignore index 3f91b6f..8b64b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Secrets .env +.env.deploy *.env.local # Local dev overrides (niet committen, per ontwikkelaar) diff --git a/README.md b/README.md index 046bf98..825abe7 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,34 @@ docker compose logs -f > 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 diff --git a/bin/deploy-to-nas.sh b/bin/deploy-to-nas.sh new file mode 100755 index 0000000..409c9c3 --- /dev/null +++ b/bin/deploy-to-nas.sh @@ -0,0 +1,112 @@ +#!/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" From e4e0760b1b5b544b9b7d0e91b0039a3bc5814e6d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 04:28:38 +0200 Subject: [PATCH 05/16] =?UTF-8?q?feat(M13=20T-520a):=20bin/worker-quota-pr?= =?UTF-8?q?obe.sh=20=E2=80=94=20pre-flight=20quota-meting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lichtgewicht /v1/messages-call (1 token max) → parse anthropic-ratelimit-*-headers → JSON op stdout. Output: { remaining, limit, pct, reset_at_iso, http_status } Of: { error, http_status } bij fout. Worker-loop gebruikt pct in vergelijking met min_quota_pct (uit mcp__scrum4me__get_worker_settings) om stand-by-modus te bepalen. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/worker-quota-probe.sh | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100755 bin/worker-quota-probe.sh diff --git a/bin/worker-quota-probe.sh b/bin/worker-quota-probe.sh new file mode 100755 index 0000000..908f33f --- /dev/null +++ b/bin/worker-quota-probe.sh @@ -0,0 +1,70 @@ +#!/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" From cf6969733c6d576e16a6807b1d5f81e1767b9676 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 04:36:43 +0200 Subject: [PATCH 06/16] feat(M13): worker-loop pre-flight quota-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drukt M13 pre-flight quota-gate op de batch-loop: - ALLOWED_TOOLS in run-agent.sh uitgebreid met mcp__scrum4me__get_worker_settings + worker_heartbeat (anders mag Claude ze niet aanroepen ondanks dat ze geregistreerd zijn). - CLAUDE.md operationele loop krijgt stap 0 vóór wait_for_job: get_worker_settings → bin/worker-quota-probe.sh → worker_heartbeat → sleep-tot-reset bij low quota. Hierna draait de complete keten end-to-end: worker meet quota, rapporteert aan server, NavBar toont stand-by-badge wanneer pct < user.min_quota_pct. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +++++++++++ bin/run-agent.sh | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cadeabc..3ffe6e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,17 @@ in de Scrum4Me-repo. Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"* of equivalent: +0. **Pre-flight quota-check** (M13). Vóór elke `wait_for_job`-aanroep: + 1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }` + 2. `bash /opt/agent/bin/worker-quota-probe.sh` → JSON + `{ pct, reset_at_iso, ... }` + 3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, + last_quota_check_at })` — server stuurt SSE-event zodat NavBar + stand-by-badge live updatet + 4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot + `reset_at_iso`", sleep tot dat tijdstip (cap op 1 uur), spring + terug naar stap 0.2 + 5. **Anders**: ga door naar stap 1 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: diff --git a/bin/run-agent.sh b/bin/run-agent.sh index d392c9c..9097261 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -46,7 +46,7 @@ SEED_PROMPT='Pak de volgende job uit de Scrum4Me-queue en draai de queue leeg vo # 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' +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,mcp__scrum4me__get_worker_settings,mcp__scrum4me__worker_heartbeat' CONSEC_FAILURES=0 BACKOFF=${AGENT_BACKOFF_START} From 318a071f1122b67359bad08b77cdd8a1236d4260 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 08:05:54 +0200 Subject: [PATCH 07/16] chore(deploy): docker resources sizing for Apple M2 host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verruimt de container-limits van 3 cores / 4 GB (oude QNAP N5095 target) naar 6 cores / 8 GB voor draaien op een Apple M2 met 16 GB unified memory. Reservations omhoog naar 1 core / 1 GB. Aanname: M2 base (8 cores). Pas omlaag aan voor M2 base met 8 GB host of omhoog voor M2 Pro/Max. NB: README.md en CLAUDE.md verwijzen nog steeds naar QNAP/N5095. Niet in deze PR aangepast — apart op te schonen als de QNAP-deploy definitief wegvalt. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f12b762..e00552d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,16 +29,18 @@ services: restart: unless-stopped - # N5095 heeft 4 cores. Geef agent er 3, laat 1 voor QTS. - # Memory: 4GB is ruim voor één Claude Code sessie + één npm install. + # 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. deploy: resources: limits: - cpus: "3.0" - memory: 4g + cpus: "6.0" + memory: 8g reservations: - cpus: "0.5" - memory: 512m + cpus: "1.0" + memory: 1g healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] From 8fc39f7492c8544f01f75c21eb538cee6789f2cd Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 08:13:43 +0200 Subject: [PATCH 08/16] chore(deploy): wire Claude Code PostToolUse hook into image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrum4me-mcp PR #26 voegde een PostToolUse-hook toe (.claude/settings.json + scripts/persist-job-usage.ts) die per job tokenusage uit het lokale Claude Code transcript leest en op claude_jobs schrijft. In de container draait Claude Code echter met cwd=/opt/agent en HOME=/home/agent — daar zoekt 't naar .claude/settings.json. De file van /opt/scrum4me-mcp/.claude wordt dus nooit ingelezen zonder een explicit copy. Twee aanpassingen: 1. RUN-step kopieert /opt/scrum4me-mcp/.claude/settings.json naar /home/agent/.claude/settings.json (user-scope, fireert ongeacht cwd). 2. ENV SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp zodat het hook-commando (`tsx ${SCRUM4ME_MCP_DIR:-$CLAUDE_PROJECT_DIR}/scripts/persist-job-usage.ts`) het script vindt vanuit elke cwd. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e01b8fe..0900239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,17 @@ 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 \ @@ -100,7 +111,8 @@ 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 + AGENT_HEALTH_PORT=8080 \ + SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp EXPOSE 8080 From b6bea1ecbbf99f5d5de17970d3c579e243d1da8d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:54:42 +0200 Subject: [PATCH 09/16] PBI-50 F0-2 + F5: PER_TASK verify-gate + SPRINT_IMPLEMENTATION-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PER_TASK-loop uitgebreid met verify_task_against_plan en update_task_status vóór update_job_status (eenmalige investering, ook PER_TASK-flow profiteert). Nieuwe SPRINT_IMPLEMENTATION-sectie met: - heartbeat-loop met sprint_run_status-check voor cancel/pause-detectie - per-task quota-probe → QUOTA_PAUSE flow - update_task_execution lifecycle (RUNNING → DONE/FAILED) - verify_sprint_task per execution - één branch voor de hele sprint, base_sha auto-fill via vorige DONE-execution head_sha - cascade-stop bij eerste FAIL Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ffe6e7..82e6fb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,31 +50,103 @@ of equivalent: 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 zelf pushen of PR's maken.** Lokaal committen op een + 6. **Verify-gate** (PBI-50 F0-2). Roep + `mcp__scrum4me__verify_task_against_plan({ task_id, worktree_path })` + aan. De tool draait `git diff ...HEAD` en classificeert tegen + het frozen `implementation_plan`. Antwoord bevat `verify_result` + + `allowed_for_done`. Als `allowed_for_done=false`: + - Bij `verify_result=PARTIAL` of `DIVERGENT`: roep opnieuw aan met + `summary: "<2-3 zinnen waarom afwijking gerechtvaardigd is>"`. + - Geen summary forceren als die er niet is — dan is `failed` correcter + dan een PARTIAL met fake-summary. + 7. **Per-task status** (PBI-50 F0-2). Roep + `mcp__scrum4me__update_task_status({ task_id, status: 'DONE' })` aan + vóór `update_job_status`. Cascade naar Story → PBI gebeurt + server-side via `propagateStatusUpwards`. + 8. **Niet zelf pushen of PR's maken.** Lokaal committen op een feature-branch is goed. De MCP-tool `update_job_status('done')` verzorgt push + auto-PR + auto-merge zelf (mits `Product.auto_pr=true`). - 7. Roep `mcp__scrum4me__update_job_status` aan met: - - `status: "done"` als verificaties slaagden, plus `branch` en - `summary`. + 9. Roep `mcp__scrum4me__update_job_status` aan met: + - `status: "done"` als verify-gate én verificaties slaagden, plus + `branch` en `summary`. - `status: "failed"` met `error` als iets onomkeerbaar misging. - Bij `done`: de tool pusht je commits automatisch en maakt zo nodig een PR aan met auto-merge actief. Verwacht dus dat de respons `pushed_at` en `pr_url` kan bevatten. - 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 + 10. 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. + 11. 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 10: 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). +## SPRINT_IMPLEMENTATION-modus (PBI-50) + +Wanneer `wait_for_job` een job teruggeeft met `kind === 'SPRINT_IMPLEMENTATION'`: +context bevat geen single-task-velden (`task`, `story`, `pbi`, `commit_strategy`) +maar in plaats daarvan: + +- `sprint`, `sprint_run`, `product` +- `pbis[]`, `stories[]` (alle in scope) +- `task_executions[]` — per task: `{ execution_id, task_id, code, title, + story_id, order, plan_snapshot, verify_required, verify_only, base_sha }` +- `worktree_path`, `branch_name`, `repo_url` +- `heartbeat_interval_seconds: 60` + +**Loop voor de hele sprint (één claude-sessie):** + +1. Lees project-CLAUDE.md (voor coding-standards) — dezelfde stap als PER_TASK. +2. Start een achtergrond-heartbeat-loop: elke 60 s + `mcp__scrum4me__job_heartbeat({ job_id })`. De respons bevat + `sprint_run_status` + `sprint_run_pause_reason`. Bij `sprint_run_status !== + 'RUNNING'`: breek de task-loop direct (UI-cancel of sibling-fail). +3. Voor elke `execution` in `task_executions[]` (al gesorteerd op order): + 1. **Quota-probe** (PBI-50 F4-T3). `worker_quota-probe.sh` → + `worker_heartbeat({ last_quota_pct })`. Als `pct < min_quota_pct`: + maak de huidige task af (commit + verify + execution DONE), roep + dan `update_job_status('failed', error: "QUOTA_PAUSE: pct=")` + aan. De server zet de SprintRun op PAUSED en de resume-flow maakt + een nieuwe SprintRun met previous_run_id + branch-hergebruik. + 2. `update_task_execution({ execution_id, status: 'RUNNING' })`. + 3. Voer `plan_snapshot` uit. Commit per laag in dezelfde branch + (`branch_name` is gelijk aan `sprint_run.branch`). ST-codes per task. + 4. Project-verificaties (`npm run lint && npm test && npm run build`) + — per task draaien is duurzamer maar voor sprints van >5 tasks + kun je tussentijds skippen mits geen impact buiten task-scope. + 5. `verify_sprint_task({ execution_id, worktree_path, summary? })`. + Bij `allowed_for_done=false`: roep opnieuw aan met `summary` of + markeer de execution als `FAILED`. Bij FAILED: cascade-stop — + `update_task_execution(FAILED)` + `update_task_status(FAILED, + sprint_run_id)` + `update_job_status('failed', error: "task : + ")`. De rest van de task_executions wordt niet uitgevoerd. + 6. `update_task_execution({ execution_id, status: 'DONE', head_sha: + })`. + 7. `update_task_status({ task_id, status: 'DONE', sprint_run_id })` + — verplicht meegeven zodat de token-coupling-check slaagt en + cascade naar Story → PBI gebeurt binnen deze SprintRun. +4. Aan het eind van alle tasks (geen FAIL en geen quota-pause): + `update_job_status('done', branch, summary: "")`. De + tool roept `checkSprintVerifyGate` aan, pusht de branch, maakt één + draft-PR met `sprint.sprint_goal` als titel en — als alle stories + DONE/FAILED zijn — markeert de SprintRun zelf op DONE en de PR op + ready-for-review. +5. Stop de heartbeat-loop, ga naar `check_queue_empty` zoals PER_TASK. + +**Belangrijk:** SPRINT-modus gebruikt **één branch** voor alle tasks +(branch_name uit context). Geen branch-wissels per task. De +`base_sha` voor task[0] zit in execution.base_sha; task[1..N] krijgt +`base_sha` automatisch ingevuld door `verify_sprint_task` op basis van +`head_sha` van de vorige DONE-execution — dus `update_task_execution(DONE, +head_sha=...)` is **kritiek** voor de chain. + ## Foutscenario's - **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer From a6079892d75c2eb7ec05a636d93d71cd5d400495 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 17:22:43 +0200 Subject: [PATCH 10/16] =?UTF-8?q?feat(PBI-4/ST-005):=20runner=20haalt=20qu?= =?UTF-8?q?eue-loop=20uit=20Claude=20(=C3=A9=C3=A9n=20invocation=20per=20j?= =?UTF-8?q?ob)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vervangt de lange seed-prompt-loop door een Node-runner die per iteratie precies één geclaimde job afhandelt. Eén Claude-invocation = één job met de juiste per-kind config (model/permission-mode/effort/allowed_tools) volgens PBI-67's resolveJobConfig. - T-18/19/20/21: bin/run-one-job.ts (nieuw, ESM tsx). Imports direct uit /opt/scrum4me-mcp/src/. Stappen: auth → quota-probe → claim met LISTEN-fallback 270s → getFullJobContext → attachWorktreeToJob (TASK) → payload schrijven → CLI-args bouwen + mapBudgetToEffort → spawn claude → token-expiry detection → rollbackClaim bij exit≠0 zonder update_job_status → cleanup. Logging met ISO-timestamps voor elke fase. setInterval(60s) lease-renewal alleen voor SPRINT_IMPLEMENTATION. - T-22: bin/run-agent.sh — SEED_PROMPT + ALLOWED_TOOLS verwijderd; claude -p vervangen door `tsx /opt/agent/bin/run-one-job.ts`. TOKEN_EXPIRED detectie uitgebreid met exit_code==3 trigger. - T-23: CLAUDE.md herschreven — operationele loop weg, architectuur- uitleg toegevoegd, hardstop-regels (geen wait_for_job, check_queue_empty, job_heartbeat, git push). T-24 smoke-test gedeferd tot na merge scrum4me-mcp PR (Dockerfile clone't via MCP_GIT_REF, default 'main'); zie test_result-log voor verificatie- commando's. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 236 ++++++++------------------- bin/run-agent.sh | 49 +++--- bin/run-one-job.ts | 387 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+), 197 deletions(-) create mode 100644 bin/run-one-job.ts diff --git a/CLAUDE.md b/CLAUDE.md index 82e6fb5..dea1f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,189 +1,85 @@ # CLAUDE.md — Scrum4Me NAS-runner -Je draait als headless worker op een QNAP NAS. Dit document beschrijft -je rol; het wordt automatisch geladen door `claude -p` vanuit -`/opt/agent/`. +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. ## 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` - zullen jouw token tonen. -- Je hebt **geen handmatige push- of PR-acties nodig.** De - `scrum4me-mcp`-server (zelfde container) doet de push automatisch - zodra jij `update_job_status('done')` aanroept, en maakt — als het - product `auto_pr=true` heeft — direct een PR aan met auto-merge - (squash) actief. Roep dus geen `git push` of `gh pr create` zelf aan; - laat de MCP-laag dat doen. -- Je opereert binnen `/tmp/job-` per job. Buiten die directory en - buiten `/var/log/agent` heb je niets te zoeken. + 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`). -Volledige documentatie van de auto-PR-keten: `docs/runbooks/auto-pr-flow.md` -in de Scrum4Me-repo. +## 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) -0. **Pre-flight quota-check** (M13). Vóór elke `wait_for_job`-aanroep: - 1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }` - 2. `bash /opt/agent/bin/worker-quota-probe.sh` → JSON - `{ pct, reset_at_iso, ... }` - 3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, - last_quota_check_at })` — server stuurt SSE-event zodat NavBar - stand-by-badge live updatet - 4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot - `reset_at_iso`", sleep tot dat tijdstip (cap op 1 uur), spring - terug naar stap 0.2 - 5. **Anders**: ga door naar stap 1 -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. **Verify-gate** (PBI-50 F0-2). Roep - `mcp__scrum4me__verify_task_against_plan({ task_id, worktree_path })` - aan. De tool draait `git diff ...HEAD` en classificeert tegen - het frozen `implementation_plan`. Antwoord bevat `verify_result` + - `allowed_for_done`. Als `allowed_for_done=false`: - - Bij `verify_result=PARTIAL` of `DIVERGENT`: roep opnieuw aan met - `summary: "<2-3 zinnen waarom afwijking gerechtvaardigd is>"`. - - Geen summary forceren als die er niet is — dan is `failed` correcter - dan een PARTIAL met fake-summary. - 7. **Per-task status** (PBI-50 F0-2). Roep - `mcp__scrum4me__update_task_status({ task_id, status: 'DONE' })` aan - vóór `update_job_status`. Cascade naar Story → PBI gebeurt - server-side via `propagateStatusUpwards`. - 8. **Niet zelf pushen of PR's maken.** Lokaal committen op een - feature-branch is goed. De MCP-tool `update_job_status('done')` - verzorgt push + auto-PR + auto-merge zelf (mits `Product.auto_pr=true`). - 9. Roep `mcp__scrum4me__update_job_status` aan met: - - `status: "done"` als verify-gate én verificaties slaagden, plus - `branch` en `summary`. - - `status: "failed"` met `error` als iets onomkeerbaar misging. - - Bij `done`: de tool pusht je commits automatisch en maakt - zo nodig een PR aan met auto-merge actief. Verwacht dus dat - de respons `pushed_at` en `pr_url` kan bevatten. - 10. 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. - 11. 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 10: 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). - -## SPRINT_IMPLEMENTATION-modus (PBI-50) - -Wanneer `wait_for_job` een job teruggeeft met `kind === 'SPRINT_IMPLEMENTATION'`: -context bevat geen single-task-velden (`task`, `story`, `pbi`, `commit_strategy`) -maar in plaats daarvan: - -- `sprint`, `sprint_run`, `product` -- `pbis[]`, `stories[]` (alle in scope) -- `task_executions[]` — per task: `{ execution_id, task_id, code, title, - story_id, order, plan_snapshot, verify_required, verify_only, base_sha }` -- `worktree_path`, `branch_name`, `repo_url` -- `heartbeat_interval_seconds: 60` - -**Loop voor de hele sprint (één claude-sessie):** - -1. Lees project-CLAUDE.md (voor coding-standards) — dezelfde stap als PER_TASK. -2. Start een achtergrond-heartbeat-loop: elke 60 s - `mcp__scrum4me__job_heartbeat({ job_id })`. De respons bevat - `sprint_run_status` + `sprint_run_pause_reason`. Bij `sprint_run_status !== - 'RUNNING'`: breek de task-loop direct (UI-cancel of sibling-fail). -3. Voor elke `execution` in `task_executions[]` (al gesorteerd op order): - 1. **Quota-probe** (PBI-50 F4-T3). `worker_quota-probe.sh` → - `worker_heartbeat({ last_quota_pct })`. Als `pct < min_quota_pct`: - maak de huidige task af (commit + verify + execution DONE), roep - dan `update_job_status('failed', error: "QUOTA_PAUSE: pct=")` - aan. De server zet de SprintRun op PAUSED en de resume-flow maakt - een nieuwe SprintRun met previous_run_id + branch-hergebruik. - 2. `update_task_execution({ execution_id, status: 'RUNNING' })`. - 3. Voer `plan_snapshot` uit. Commit per laag in dezelfde branch - (`branch_name` is gelijk aan `sprint_run.branch`). ST-codes per task. - 4. Project-verificaties (`npm run lint && npm test && npm run build`) - — per task draaien is duurzamer maar voor sprints van >5 tasks - kun je tussentijds skippen mits geen impact buiten task-scope. - 5. `verify_sprint_task({ execution_id, worktree_path, summary? })`. - Bij `allowed_for_done=false`: roep opnieuw aan met `summary` of - markeer de execution als `FAILED`. Bij FAILED: cascade-stop — - `update_task_execution(FAILED)` + `update_task_status(FAILED, - sprint_run_id)` + `update_job_status('failed', error: "task : - ")`. De rest van de task_executions wordt niet uitgevoerd. - 6. `update_task_execution({ execution_id, status: 'DONE', head_sha: - })`. - 7. `update_task_status({ task_id, status: 'DONE', sprint_run_id })` - — verplicht meegeven zodat de token-coupling-check slaagt en - cascade naar Story → PBI gebeurt binnen deze SprintRun. -4. Aan het eind van alle tasks (geen FAIL en geen quota-pause): - `update_job_status('done', branch, summary: "")`. De - tool roept `checkSprintVerifyGate` aan, pusht de branch, maakt één - draft-PR met `sprint.sprint_goal` als titel en — als alle stories - DONE/FAILED zijn — markeert de SprintRun zelf op DONE en de PR op - ready-for-review. -5. Stop de heartbeat-loop, ga naar `check_queue_empty` zoals PER_TASK. - -**Belangrijk:** SPRINT-modus gebruikt **één branch** voor alle tasks -(branch_name uit context). Geen branch-wissels per task. De -`base_sha` voor task[0] zit in execution.base_sha; task[1..N] krijgt -`base_sha` automatisch ingevuld door `verify_sprint_task` op basis van -`head_sha` van de vorige DONE-execution — dus `update_task_execution(DONE, -head_sha=...)` is **kritiek** voor de chain. +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. ## Foutscenario's -- **`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. +- **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. ## Vraag-antwoord-kanaal (M11) -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. +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. -Niet gokken. Niet aannemen. +## Verwijzingen -## Wat je NIET doet - -- Geen handmatige `git push`. De MCP-tool `update_job_status('done')` - pusht zelf via `pushBranchForJob`. Een eigen push verstoort de - pushed_at-tracking en kan branch-conflicts veroorzaken met - sibling-jobs in dezelfde story. -- Geen `gh pr create` of `gh pr merge`. De MCP-tool `maybeCreateAutoPr` - doet dit afhankelijk van `Product.auto_pr`. -- Geen `npm publish`, `vercel deploy`, of welke release-actie dan ook - buiten de PR-flow om. -- 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. +- 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. diff --git a/bin/run-agent.sh b/bin/run-agent.sh index 9097261..c67213a 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -3,14 +3,19 @@ # # Strategie: # - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal) -# - Loop: claude -p met seed-prompt -# - Exit 0 → de queue was leeg, sleep kort, herhaal +# - 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 # - 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 @@ -40,13 +45,10 @@ 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 -# ----- 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,mcp__scrum4me__get_worker_settings,mcp__scrum4me__worker_heartbeat' +# 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). CONSEC_FAILURES=0 BACKOFF=${AGENT_BACKOFF_START} @@ -60,32 +62,25 @@ while true; do --argjson failures "$CONSEC_FAILURES" \ '{status:"running", currentBatchStartedAt:$started, consecutiveFailures:$failures}')" - log "starting batch (log: ${run_log})" + log "starting iteration (log: ${run_log})" - # 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. + # 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. set +e - claude -p "${SEED_PROMPT}" \ - --mcp-config /opt/agent/mcp-config.json \ - --allowedTools "${ALLOWED_TOOLS}" \ - --permission-mode bypassPermissions \ - --output-format text \ - > "${run_log}" 2>&1 + tsx /opt/agent/bin/run-one-job.ts > "${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: 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" + # 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). + if [[ "$exit_code" -eq 3 ]] || 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" write_state "$(jq -n \ --arg endedAt "$iteration_end" \ diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts new file mode 100644 index 0000000..2aaa50c --- /dev/null +++ b/bin/run-one-job.ts @@ -0,0 +1,387 @@ +#!/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, writeFileSync } from 'node:fs' + +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, + releaseLocksOnTerminal, + resetStaleClaimedJobs, + rollbackClaim, + tryClaimJob, +} from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' +import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js' +import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.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 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 ---------------------------------------------------- +async function quotaProbe(userId: string): Promise { + const probe = spawnSync(QUOTA_PROBE_PATH, [], { encoding: 'utf8' }) + if (probe.status !== 0) { + logError( + `quota probe failed: status=${probe.status} stderr=${(probe.stderr ?? '').trim()}`, + ) + throw new Error('quota probe failed') + } + let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string } + try { + parsed = JSON.parse(probe.stdout) + } catch { + logError(`quota probe stdout not JSON: ${probe.stdout.slice(0, 200)}`) + throw new Error('quota probe stdout invalid') + } + if (parsed.pct === undefined) { + logError(`quota probe missing pct field: ${probe.stdout.slice(0, 200)}`) + throw new Error('quota probe missing pct') + } + + 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}`) + + // 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}`) + + // 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) + 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', + 'text', + ] + 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. + let tokenExpired = false + 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 +} + +// ----- 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) + }) From 4d28e084dd2b0a718b9dfb21aea9ba218dcb67f9 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 11:19:06 +0200 Subject: [PATCH 11/16] fix(runner): NODE_PATH voor pg-resolution + cache-bust ARG + soft quota-probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie kleine fixes voor de runner uit PBI-4 die in een lokale smoke-test naar boven kwamen: 1. NODE_PATH=/opt/scrum4me-mcp/node_modules in Dockerfile ENV — anders vindt tsx de top-level `pg` import in bin/run-one-job.ts niet (resolve start vanaf /opt/agent/bin/, zoekt geen scrum4me-mcp/node_modules). 2. ARG MCP_CACHE_BUST in Dockerfile vóór de scrum4me-mcp clone-laag. BuildKit cached anders de clone op MCP_GIT_REF=main, ook als main intussen nieuwere commits heeft. Rebuild met `--build-arg MCP_CACHE_BUST=$(date +%s)` invalidate't deze laag deterministisch. 3. quotaProbe in run-one-job.ts soft-failt nu bij niet-zero exit, geen pct-veld, of geen rate-limit-headers in response. De Anthropic API retourneert niet altijd headers; dit zou de runner niet hard moeten crashen. Komt overeen met CLAUDE.md stap 0.4 ("anders: ga door"). Lokale smoke-test bevestigt nu dat een IDEA_GRILL job correct geclaimd wordt met `--model=claude-sonnet-4-6 --permission-mode=plan --effort=high` en de juiste 10 allowed_tools. Apart probleem ontdekt (NIET in deze PR): IDEA_GRILL/IDEA_MAKE_PLAN/ PLAN_CHAT draaien default in --permission-mode plan. In autonomous batch- mode kan Claude in plan-mode mogelijk geen update_job_status aanroepen (plan-mode wacht op human approval), waardoor jobs FAILED raken na 2x lease-expiry. Verdient eigen issue/PR voor permission_mode review. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 10 ++++++++-- bin/run-one-job.ts | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0900239..98fe2b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,13 @@ RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} \ # niet om te builden. Pin via build-arg; default = main. 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 git clone --branch ${MCP_GIT_REF} --depth 1 \ +RUN echo "cache-bust=${MCP_CACHE_BUST}" \ + && 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 \ @@ -112,7 +117,8 @@ ENV PATH=/opt/agent/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin: AGENT_REPO_CACHE=/var/cache/repos \ AGENT_JOB_ROOT=/tmp \ AGENT_HEALTH_PORT=8080 \ - SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp + SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp \ + NODE_PATH=/opt/scrum4me-mcp/node_modules EXPOSE 8080 diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts index 2aaa50c..cc1ce0b 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -60,24 +60,28 @@ const TOKEN_EXPIRY_PATTERNS: RegExp[] = [ ] // ----- 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) { - logError( - `quota probe failed: status=${probe.status} stderr=${(probe.stderr ?? '').trim()}`, + log( + `quota probe non-zero status=${probe.status} stdout=${probe.stdout.slice(0, 200).trim()} — continuing without gate`, ) - throw new Error('quota probe failed') + return } - let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string } + let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string; error?: string } try { parsed = JSON.parse(probe.stdout) } catch { - logError(`quota probe stdout not JSON: ${probe.stdout.slice(0, 200)}`) - throw new Error('quota probe stdout invalid') + log(`quota probe stdout not JSON (continuing): ${probe.stdout.slice(0, 200)}`) + return } if (parsed.pct === undefined) { - logError(`quota probe missing pct field: ${probe.stdout.slice(0, 200)}`) - throw new Error('quota probe missing pct') + log(`quota probe no pct (continuing): error=${parsed.error ?? '-'}`) + return } const user = await prisma.user.findUnique({ From 2a1fb5677e25b5b01d0433bb25788a5f9f6d8b8f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 14:07:49 +0200 Subject: [PATCH 12/16] fix(runner): import releaseLocksOnTerminal uit git/job-locks.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-one-job.ts importeerde releaseLocksOnTerminal uit '/opt/scrum4me-mcp/src/tools/wait-for-job.js' maar die module re-exporteert deze symbol niet (alleen lokaal geïmporteerd uit ../git/job-locks.js). Resultaat: bij elke rollbackClaim-pad (worktree-fout, getFullJobContext- fout, claude exit≠0 zonder update_job_status) crasht run-one-job met: TypeError: (0 , import_wait_for_job.releaseLocksOnTerminal) is not a function Fix: importeer direct uit /opt/scrum4me-mcp/src/git/job-locks.js (zelfde pad als wait-for-job.ts en cancel/pbi-cascade.ts intern doen). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/run-one-job.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts index cc1ce0b..0b3580b 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -31,11 +31,11 @@ import { prisma } from '/opt/scrum4me-mcp/src/prisma.js' import { attachWorktreeToJob, getFullJobContext, - releaseLocksOnTerminal, 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' From e8c4518abbb6d95a4cd957c76f3885fa29e611c3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 02:30:24 +0200 Subject: [PATCH 13/16] fix(runner): registreer worker-presence + 10s heartbeat in run-one-job Tot nu toe schreef de NAS-runner nooit naar `claude_workers`, waardoor de UI de worker als offline toonde ondanks gezonde container-health. Direct na `getAuth()` doen we nu een UPSERT via `registerWorker` en starten we een 10s heartbeat die `last_seen_at` vers houdt tijdens quota-backoff, LISTEN-wait, claude-spawn en cleanup. De heartbeat stopt via try/finally op elk exit-pad. Bewust geen `unregisterWorker`: tussen iteraties zou dat UI-flicker geven, en abnormale exits worden door de UI's eigen 60s-prune opgevangen. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/run-one-job.ts | 419 ++++++++++++++++++++++++--------------------- 1 file changed, 221 insertions(+), 198 deletions(-) diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts index 0b3580b..3da7cf9 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -38,6 +38,8 @@ import { 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) => @@ -49,6 +51,7 @@ const logError = (msg: string) => 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 @@ -152,225 +155,245 @@ async function main(): Promise { const { userId, tokenId } = await getAuth() log(`auth ok user_id=${userId} token_id=${tokenId}`) - // 1. Quota probe (gate vóór elke claim). + // 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 quotaProbe(userId) + await registerWorker({ userId, tokenId }) } catch (err) { - logError(`quota probe error: ${(err as Error).message}`) - return 1 + logError(`registerWorker failed (non-fatal): ${(err as Error).message}`) } + const workerHeartbeat = startHeartbeat({ + userId, + tokenId, + intervalMs: WORKER_HEARTBEAT_INTERVAL_MS, + }) - // 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) + 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) - jobId = await tryClaimJob(userId, tokenId) - } - if (!jobId) { - log(`claim timeout after ${WAIT_DEADLINE_SECONDS}s — exiting 0`) - return 0 - } + 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}`) + log(`claimed job_id=${jobId}`) - // 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`) + // 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) - 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`) + if (!ctx) { + logError(`getFullJobContext returned null for job_id=${jobId}`) 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}`, - ) + // 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 - // 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) - 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', - 'text', - ] - 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. - let tokenExpired = false - 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`, + 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) + 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', + 'text', + ] + 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) } - } - // 12. Cleanup payload directory. - try { - rmSync(payloadDir, { recursive: true, force: true }) - } catch { - // non-fatal - } - log(`cleanup payload_removed=true heartbeat_stopped=${heartbeatTimer !== null}`) + 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)}`, + ) - if (tokenExpired) return 3 - return exitCode ?? 1 + // 10. Token-expiry detection. + let tokenExpired = false + 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 ---------------------------------------------------------- From 0b5a044ea5c4fc14f1d0c66bd4d5d05719b35d9d Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 19:22:40 +0200 Subject: [PATCH 14/16] feat(logs): per-job log-symlink jobs/.log -> runs/.log (IDEA-063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run-logs in /var/log/agent/runs/ zijn timestamp-named, dus de output van een specifieke job was alleen via grep te vinden. De map jobs/ bestond al maar werd niet gevuld. - run-agent.sh: geeft het run-log-pad door als RUN_LOG env-var aan run-one-job.ts. - run-one-job.ts: legt direct na de claim een symlink jobs/.log -> ../runs/.log. Relatief pad (overleeft de host bind-mount), best-effort (faalt de job nooit over een log-gemak). - log-cleanup.sh: ruimt dangling per-job symlinks op met `find -xtype l` — nodig omdat rotate-logs.sh het doel na 24u gzipt (.log -> .log.gz) of na 30d verwijdert, en de bestaande `-type f` cleanup symlinks niet raakt. Functioneel geverifieerd: symlink resolveert, dangling-prune werkt, `-type f` negeert de symlink (geen voortijdige delete). run-one-job.ts parseert schoon (node --check + type-strip). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/log-cleanup.sh | 5 +++++ bin/run-agent.sh | 4 +++- bin/run-one-job.ts | 22 +++++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/bin/log-cleanup.sh b/bin/log-cleanup.sh index dbd8a0d..5a4ad85 100755 --- a/bin/log-cleanup.sh +++ b/bin/log-cleanup.sh @@ -18,4 +18,9 @@ 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/run-agent.sh b/bin/run-agent.sh index c67213a..52c6f49 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -68,7 +68,9 @@ while true; do # claimt zelf via tryClaimJob, leest JobConfig (PBI-67), bouwt de # juiste Claude CLI-args, spawnt 'claude', wacht, sluit af. set +e - tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 + # 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 exit_code=$? set -e diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts index 3da7cf9..cd0919c 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -22,7 +22,8 @@ // 3 = TOKEN_EXPIRED detected → run-agent.sh schrijft TOKEN_EXPIRED marker import { spawn, spawnSync } from 'node:child_process' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' import { Client as PgClient } from 'pg' @@ -196,6 +197,25 @@ async function main(): Promise { 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 { From c64c0278f2396c3908ff57aa8511d72c086f4039 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 19:29:14 +0200 Subject: [PATCH 15/16] feat(worker): configureerbare Claude --output-format, default stream-json (IDEA-064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-one-job.ts spawnde Claude met een hardcoded --output-format text, dus de run-log bevatte alleen Claude's eind-samenvatting — geen zicht op het werk tijdens een job (~6-10 min stilte, dan ineens de samenvatting). - --output-format komt nu uit AGENT_CLAUDE_OUTPUT_FORMAT (default 'stream-json'). stream-json streamt elke tool-call / elk bericht live naar de run-log; --verbose wordt automatisch toegevoegd want print-mode vereist dat bij stream-json. - Zet AGENT_CLAUDE_OUTPUT_FORMAT=text terug voor de oude terse output. - .env.example: nieuwe var gedocumenteerd. stdoutBuf wordt alleen voor de TOKEN_EXPIRED-regexscan gebruikt; de auth-error-strings staan ook binnen de JSON-events, dus detectie werkt ongewijzigd. Niets parseert de output als job-resultaat. Gevolg: de run-log (en de jobs/.log symlink uit IDEA-063) wordt JSONL i.p.v. plain text — gebruik jq of een viewer. Log-grootte groeit; rotate-logs.sh dekt dat al af. node --check + type-strip schoon. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 6 ++++++ bin/run-one-job.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3f3a523..0acc9f9 100644 --- a/.env.example +++ b/.env.example @@ -108,3 +108,9 @@ 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/bin/run-one-job.ts b/bin/run-one-job.ts index cd0919c..8155fea 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -292,6 +292,13 @@ async function main(): Promise { // 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, @@ -306,8 +313,9 @@ async function main(): Promise { '--add-dir', '/opt/agent', '--output-format', - 'text', + outputFormat, ] + if (outputFormat === 'stream-json') args.push('--verbose') if (effort) args.push('--effort', effort) const cwd = worktreePath ?? '/opt/agent' From a051bb00d43e53b8dbee21cc4ed865f855517c6d Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Fri, 15 May 2026 00:43:32 +0200 Subject: [PATCH 16/16] fix(worker): TOKEN_EXPIRED-detectie alleen bij non-zero Claude-exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-one-job.ts scant de volledige stream-json output (incl. álle tool-results) op auth-error-patronen, en run-agent.sh grept hetzelfde over het complete run-log — beide zonder de exit-code te checken. Daardoor legt een geslaagde job (exit 0, result.is_error=false) de worker plat zodra z'n output toevallig iets als "401 unauthorized" bevat — bv. wanneer de agent een doc over route-handler-auth leest of een endpoint test. run-agent.sh doet dan touch TOKEN_EXPIRED + sleep infinity en de worker draait pas na een rebuild weer. Fix: detectie gaten op een niet-nul exit. Een echte credential-fout laat 'claude' non-zero exiten, dus echte expiries worden nog steeds gevangen — alleen de false positives op geslaagde runs verdwijnen. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/run-agent.sh | 8 ++++++-- bin/run-one-job.ts | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/bin/run-agent.sh b/bin/run-agent.sh index 52c6f49..6b3dd32 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -80,8 +80,12 @@ while true; do # 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). - if [[ "$exit_code" -eq 3 ]] || grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then + # 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" write_state "$(jq -n \ diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts index 8155fea..f9cc879 100644 --- a/bin/run-one-job.ts +++ b/bin/run-one-job.ts @@ -383,13 +383,21 @@ async function main(): Promise { `duration_ms=${durationMs} wall_clock_seconds=${Math.round(durationMs / 1000)}`, ) - // 10. Token-expiry detection. + // 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 - for (const pat of TOKEN_EXPIRY_PATTERNS) { - if (pat.test(stdoutBuf)) { - tokenExpired = true - log(`TOKEN_EXPIRED detected pattern="${pat.source}" exiting code=3`) - break + 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 + } } }