Compare commits

..

No commits in common. "master" and "feat/m13-worker-quota-loop" have entirely different histories.

10 changed files with 163 additions and 621 deletions

View file

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

164
CLAUDE.md
View file

@ -1,85 +1,117 @@
# CLAUDE.md — Scrum4Me NAS-runner # CLAUDE.md — Scrum4Me NAS-runner
Je draait als headless worker op een QNAP NAS (of lokale Docker). Dit document Je draait als headless worker op een QNAP NAS. Dit document beschrijft
wordt automatisch geladen door `claude -p` vanuit `/opt/agent/` en geeft je de je rol; het wordt automatisch geladen door `claude -p` vanuit
**identiteit** en de **hardstop-regels** voor deze container. De per-job `/opt/agent/`.
**workflow** krijg je in de prompt zelf van `bin/run-one-job.ts`.
## Architectuur (sinds queue-loop-refactor)
`bin/run-agent.sh` is de daemon-loop (backoff/health/log-rotation). Elke
iteratie roept hij `tsx /opt/agent/bin/run-one-job.ts` aan. Die runner doet:
1. `getAuth``tryClaimJob` (één job, atomically).
2. `getFullJobContext` → resolved `JobConfig` (PBI-67) + payload.
3. Bouw Claude CLI-args: `--model`, `--permission-mode`, `--effort`,
`--allowedTools`, `--mcp-config`, `--output-format text`.
4. `spawn 'claude' …` met cwd = worktree_path en een **kind-specifieke
prompt** (uit `scrum4me-mcp/src/prompts/<kind>/`).
5. Wacht op exit; cleanup; loop terug naar run-agent.sh.
**Eén Claude-invocation = één geclaimde job.** Jij voert alleen die ene
job uit en sluit dan af.
## Identiteit ## Identiteit
- Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet - Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet
als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id` als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id`
tonen jouw token. zullen jouw token tonen.
- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT) - Je hebt **geen handmatige push- of PR-acties nodig.** De
of de `primary_worktree_path` (idea-jobs). Buiten die directory en `scrum4me-mcp`-server (zelfde container) doet de push automatisch
`/var/log/agent` heb je niets te zoeken. zodra jij `update_job_status('done')` aanroept, en maakt — als het
- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')` product `auto_pr=true` heeft — direct een PR aan met auto-merge
aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`). (squash) actief. Roep dus geen `git push` of `gh pr create` zelf aan;
laat de MCP-laag dat doen.
- Je opereert binnen `/tmp/job-<id>` per job. Buiten die directory en
buiten `/var/log/agent` heb je niets te zoeken.
## Hardstop-regels (gelden ongeacht je kind) Volledige documentatie van de auto-PR-keten: `docs/runbooks/auto-pr-flow.md`
in de Scrum4Me-repo.
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor ## Operationele loop (verplicht)
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.
## Project-CLAUDE.md (in worktree) Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"*
of equivalent:
De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude 0. **Pre-flight quota-check** (M13). Vóór elke `wait_for_job`-aanroep:
automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de 1. `mcp__scrum4me__get_worker_settings()``{ min_quota_pct }`
Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die 2. `bash /opt/agent/bin/worker-quota-probe.sh` → JSON
bevat de ST-code-commit-stijl, lint/test/build-commands, en project- `{ pct, reset_at_iso, ... }`
specifieke patronen. 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 <job_id> <repo_url>` aan
via Bash. Output is het pad van de working tree.
2. `cd` naar dat pad.
3. Lees de project-CLAUDE.md (`./CLAUDE.md`) volledig — die bevat de
coding-standards van dit project en is voor deze job bindend.
4. Voer het `implementation_plan` uit dat je van `wait_for_job` kreeg.
Volg de Commit Strategy uit de project-CLAUDE.md (commit per laag,
ST-code in de titel).
5. Voer de project-verificaties uit die de project-CLAUDE.md voorschrijft
(typisch `npm run lint && npm test && npm run build`).
6. **Niet 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:
- `empty: false` → ga direct naar stap 3 (`wait_for_job` opnieuw).
- `empty: true` → batch is klaar; geef recap en exit. Geen extra
`wait_for_job`-call die 600 s blokt.
9. Roep `bash /opt/agent/bin/job-cleanup.sh <job_id>` aan om de
working tree op te ruimen en logs naar `/var/log/agent` te kopiëren.
3. Op basis van stap 8: bij `empty: false` opnieuw `wait_for_job`; bij
`empty: true` direct naar stap 4. Stop niet midden in de loop, vraag
niets.
4. Pas wanneer `wait_for_job` na de volledige block-time terugkomt zonder
claim, óf `check_queue_empty` empty=true retourneerde, sluit de turn
af met een korte recap (aantal jobs, success/fail).
## Foutscenario's ## Foutscenario's
- **Verificatie faalt** (lint/test/build rood): roep - **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer
`update_job_status('failed', error: <tail>)` aan en sluit af. Geen `update_job_status('failed', error=...)` en ga door met de volgende job.
automatische fix-attempts; de eindgebruiker beslist. Niet retry'en — als de cache stuk is, zal de volgende job ook falen en
- **Verify-gate DIVERGENT**: roep `verify_task_against_plan` opnieuw aan zal de wrapper merken dat we te veel fouten op rij hebben.
met een `summary` die de afwijking onderbouwt, óf rapporteer `failed`. - **Verificatie faalt** (lint/test/build rood): rapporteer `failed` met
- **Onverwachte runtime-fout**: laat de exception propageren. De runner de tail van de output in `error`. Geen automatische fix-attempts; de
detecteert exit≠0 zonder `update_job_status` en doet rollbackClaim; eindgebruiker beslist of ze het plan aanpassen.
de wrapper-loop in run-agent.sh schrijft een run-log en herstart met - **Onverwachte runtime-fout** in de tools: laat de exception propageren.
backoff. De wrapper-loop schrijft een run-log en herstart `claude -p` met backoff.
## Vraag-antwoord-kanaal (M11) ## Vraag-antwoord-kanaal (M11)
Voor blokkerende keuzes die niet uit het plan volgen: gebruik Als het `implementation_plan` ambigu is op een keuze die niet uit de
`mcp__scrum4me__ask_user_question` met 24 `options` en `wait_seconds: 600`. acceptance-criteria volgt: gebruik `mcp__scrum4me__ask_user_question`
Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord met een korte vraag plus 24 `options`. Geef `wait_seconds: 600` mee
op vraag <id>")`. Niet gokken. Niet aannemen. zodat de tool blijft wachten. Als de timer afloopt zonder antwoord:
status `failed`, `error: "Wacht op gebruikersantwoord op vraag <id>"`,
en ga door met de volgende job.
## Verwijzingen Niet gokken. Niet aannemen.
- Per-kind workflows: zie de prompt die de runner je in `claude -p` meegeeft ## Wat je NIET doet
(komt uit `scrum4me-mcp/src/prompts/<kind>/`).
- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo. - Geen handmatige `git push`. De MCP-tool `update_job_status('done')`
- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo. 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.

View file

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

View file

@ -66,7 +66,7 @@ fouten.
`/share/Agent → /share/CACHEDEV1_DATA/Agent`. `/share/Agent → /share/CACHEDEV1_DATA/Agent`.
- Drie subdirs onder die share: `/share/Agent/cache`, `/share/Agent/logs`, - Drie subdirs onder die share: `/share/Agent/cache`, `/share/Agent/logs`,
`/share/Agent/state`. Aanmaken via File Station of via SSH na share-creatie. `/share/Agent/state`. Aanmaken via File Station of via SSH na share-creatie.
- Internet-uitgang naar `api.anthropic.com`, `git.jp-visser.nl` (Forgejo HTTPS-clone/push), `cli.github.com` (build-time voor de gh CLI), je Neon-host, `registry.npmjs.org`. - 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: > **Verifieer** vóór je deployt dat `/share/Agent` echt op disk staat:
> ```bash > ```bash
@ -85,21 +85,18 @@ fouten.
# b. SCRUM4ME_TOKEN → log in als de dedicated agent-user in # b. SCRUM4ME_TOKEN → log in als de dedicated agent-user in
# Scrum4Me, /settings/tokens, label "NAS-runner" # Scrum4Me, /settings/tokens, label "NAS-runner"
# c. DATABASE_URL/DIRECT_URL → Neon dashboard # c. DATABASE_URL/DIRECT_URL → Neon dashboard
# d. GH_TOKEN → Forgejo → avatar → Settings → # d. GH_TOKEN → github.com → Settings → Developer settings →
# Applications → Generate New Token; scope # Personal access tokens → Fine-grained.
# minimaal `write:repository` op de twee # Repository access op madhura68/Scrum4Me +
# repos (janpeter/Scrum4Me + janpeter/ # madhura68/scrum4me-mcp; Permissions:
# scrum4me-mcp). Wordt gebruikt voor clone # Contents (RW), Pull requests (RW),
# en push naar Forgejo. PBI-86 (hybride # Metadata (R). Wordt gebruikt voor clone,
# model): `gh pr create` is uit de # push en `gh pr create` (auto_pr).
# worker-flow verwijderd — de GitHub-PR
# komt via de handmatige promote-Action
# in Forgejo.
# 2. Repo op de NAS plaatsen # 2. Repo op de NAS plaatsen
ssh admin@nas ssh admin@nas
cd /share/Agent cd /share/Agent
git clone https://git.jp-visser.nl/<jij>/scrum4me-agent-runner.git git clone https://github.com/<jij>/scrum4me-agent-runner.git
cd scrum4me-agent-runner cd scrum4me-agent-runner
# 3. Env aanmaken # 3. Env aanmaken
@ -329,8 +326,7 @@ Bij elke container-start runt `bin/repo-bootstrap.sh` (als de
`agent`-user, ná drop-privileges) en zet zo'n setup neer: `agent`-user, ná drop-privileges) en zet zo'n setup neer:
1. Configureert git's credential-helper met `GH_TOKEN` zodat 1. Configureert git's credential-helper met `GH_TOKEN` zodat
`git clone`/`push` naar `https://git.jp-visser.nl/...` (Forgejo) zonder `git clone`/`push` naar `https://github.com/...` zonder prompt werkt.
prompt werkt.
2. Voor elke repo in `GH_PRECLONE_REPOS` (komma-gescheiden owner/name): 2. Voor elke repo in `GH_PRECLONE_REPOS` (komma-gescheiden owner/name):
- Bestaat `~/Projects/<name>/.git` al? → `git fetch origin --prune` - Bestaat `~/Projects/<name>/.git` al? → `git fetch origin --prune`
- Anders → fresh `git clone` - Anders → fresh `git clone`
@ -341,10 +337,8 @@ voor jobs landen vervolgens onder `~/.scrum4me-agent-worktrees/<jobId>/`
zodat de hoofd-clone niet wordt aangeraakt. zodat de hoofd-clone niet wordt aangeraakt.
Push gaat over dezelfde token: `git push -u origin feat/story-<id>` Push gaat over dezelfde token: `git push -u origin feat/story-<id>`
slaagt zonder prompt. **`gh pr create` is in PBI-86 (T-1005) verwijderd slaagt zonder prompt. `gh pr create` (voor producten met `auto_pr=true`)
uit de worker-flow** — de GitHub-PR ontstaat via een handmatig gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect.
getriggerde promote-Action in Forgejo (zie de Scrum4Me-repo
`docs/runbooks/forgejo-hybrid-flow.md`).
## Veelvoorkomende issues ## Veelvoorkomende issues

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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