Compare commits

...
Sign in to create a new pull request.

25 commits

Author SHA1 Message Date
5529f3850d Merge pull request 'feat: switch source URLs from GitHub to Forgejo' (#1) from feat/forgejo-urls into master
Reviewed-on: #1
2026-05-15 18:41:22 +02:00
Madhura68
cb8f48d49e feat: switch source URLs from GitHub to Forgejo
Hybride model (PBI-86 in Scrum4Me): de worker clonet en pusht naar
Forgejo (`origin`); GitHub-PR's ontstaan via een handmatige
promote-Action in Forgejo. Variabele-namen blijven `GH_TOKEN` en
`GH_PRECLONE_REPOS` (historisch); inhoud is voortaan een Forgejo-PAT.

- Dockerfile: MCP_GIT_REPO default →
  git.jp-visser.nl/janpeter/scrum4me-mcp.git
- bin/repo-bootstrap.sh: credential-helper host + clone-URL →
  git.jp-visser.nl
- bin/job-prepare.sh: cache-slug comment example bijgewerkt
- .env.example: documentatie + default `GH_PRECLONE_REPOS` naar
  janpeter/Scrum4Me + janpeter/scrum4me-mcp; instructies omgezet naar
  Forgejo-PAT-flow; `gh pr create` (auto_pr) verwijderd uit comment.
- README.md: internet-egress, token-instructies, clone-URL en
  repo-bootstrap-sectie verwijzen nu naar Forgejo. Promote-flow gelinkt.

gh CLI install blijft in Dockerfile staan (no-op zonder gh-aanroepen,
maar weinig kosten om voor ad-hoc gebruik te bewaren).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:59:11 +02:00
Janpeter Visser
28ef6818a3
Merge pull request #20 from madhura68/fix/token-expired-false-positive
fix(worker): TOKEN_EXPIRED-detectie alleen bij non-zero Claude-exit
2026-05-15 01:03:01 +02:00
Janpeter Visser
a051bb00d4 fix(worker): TOKEN_EXPIRED-detectie alleen bij non-zero Claude-exit
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) <noreply@anthropic.com>
2026-05-15 00:43:32 +02:00
Janpeter Visser
1a87bee280
Merge pull request #19 from madhura68/feat/configurable-claude-output-format
feat(worker): configureerbare Claude --output-format, default stream-json (IDEA-064)
2026-05-14 19:32:10 +02:00
Janpeter Visser
c64c0278f2 feat(worker): configureerbare Claude --output-format, default stream-json (IDEA-064)
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/<job_id>.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) <noreply@anthropic.com>
2026-05-14 19:29:14 +02:00
Janpeter Visser
794ad7faaa
Merge pull request #18 from madhura68/feat/per-job-log-symlink
feat(logs): per-job log-symlink jobs/<job_id>.log (IDEA-063)
2026-05-14 19:26:13 +02:00
Janpeter Visser
0b5a044ea5 feat(logs): per-job log-symlink jobs/<job_id>.log -> runs/<ts>.log (IDEA-063)
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/<job_id>.log -> ../runs/<ts>.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) <noreply@anthropic.com>
2026-05-14 19:22:40 +02:00
Janpeter Visser
7ec32c8def
Merge pull request #17 from madhura68/claude/determined-wright-14cfbf
fix(runner): worker-presence heartbeat in run-one-job
2026-05-11 02:37:41 +02:00
Madhura68
e8c4518abb 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) <noreply@anthropic.com>
2026-05-11 02:30:24 +02:00
Janpeter Visser
38c0e5f103
Merge pull request #16 from madhura68/fix/release-locks-import
fix(runner): import releaseLocksOnTerminal uit git/job-locks.js
2026-05-09 14:09:29 +02:00
Madhura68
2a1fb5677e fix(runner): import releaseLocksOnTerminal uit git/job-locks.js
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) <noreply@anthropic.com>
2026-05-09 14:07:49 +02:00
Janpeter Visser
095a277646
Merge pull request #15 from madhura68/fix/runner-node-path
fix(runner): NODE_PATH + cache-bust ARG + soft quota-probe
2026-05-09 11:21:46 +02:00
Madhura68
4d28e084dd fix(runner): NODE_PATH voor pg-resolution + cache-bust ARG + soft quota-probe
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) <noreply@anthropic.com>
2026-05-09 11:19:06 +02:00
Janpeter Visser
51a7c5dd7b
Merge pull request #14 from madhura68/feat/queue-loop-extraction
feat(PBI-4/ST-005): runner haalt queue-loop uit Claude (één invocation per job)
2026-05-09 07:11:13 +02:00
Madhura68
a6079892d7 feat(PBI-4/ST-005): runner haalt queue-loop uit Claude (één invocation per job)
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) <noreply@anthropic.com>
2026-05-08 17:22:43 +02:00
Madhura68
b6bea1ecbb PBI-50 F0-2 + F5: PER_TASK verify-gate + SPRINT_IMPLEMENTATION-loop
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) <noreply@anthropic.com>
2026-05-07 12:54:42 +02:00
Janpeter Visser
d01c8956d7
Merge pull request #13 from madhura68/chore/usage-hook-config
chore(deploy): wire Claude Code PostToolUse hook into image
2026-05-06 08:14:12 +02:00
Madhura68
8fc39f7492 chore(deploy): wire Claude Code PostToolUse hook into image
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) <noreply@anthropic.com>
2026-05-06 08:13:43 +02:00
Janpeter Visser
b189359672
Merge pull request #12 from madhura68/chore/m2-resources
chore(deploy): docker resources sizing for Apple M2 host
2026-05-06 08:08:19 +02:00
Madhura68
318a071f11 chore(deploy): docker resources sizing for Apple M2 host
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) <noreply@anthropic.com>
2026-05-06 08:05:54 +02:00
Janpeter Visser
cc9cb8a965
Merge pull request #11 from madhura68/feat/m13-worker-quota-loop
feat(M13): worker-loop pre-flight quota-check
2026-05-06 04:53:20 +02:00
Madhura68
cf6969733c feat(M13): worker-loop pre-flight quota-check
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) <noreply@anthropic.com>
2026-05-06 04:36:43 +02:00
Janpeter Visser
693ab50457
Merge pull request #10 from madhura68/feat/m13-worker-quota-probe
feat(M13 T-520a): worker-quota-probe.sh — pre-flight Anthropic quota-meting
2026-05-06 04:34:26 +02:00
Janpeter Visser
3e3d0e5b10
Merge pull request #9 from madhura68/feat/deploy-script
feat(deploy): bin/deploy-to-nas.sh — één-commando Mac→NAS redeploy
2026-05-06 03:47:47 +02:00
10 changed files with 621 additions and 152 deletions

View file

@ -22,27 +22,29 @@ CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-vervang-mij
# Als deze ge-revoked wordt: rebuild + redeploy (zie README).
SCRUM4ME_TOKEN=vervang-mij
# ----- GitHub credentials -----------------------------------
# Personal Access Token (fine-grained) met:
# - Repository access op madhura68/Scrum4Me + madhura68/scrum4me-mcp
# - Permissions: Contents (read/write), Pull requests (read/write),
# Metadata (read)
# ----- Forgejo credentials (PBI-86 hybride model) -----------
# Personal Access Token van je Forgejo-account met scope read+write
# op de Scrum4Me-repos. Variabele heet historisch nog `GH_TOKEN`;
# in het hybride model bevat 'ie een Forgejo-PAT.
#
# Gebruikt voor:
# 1. Pre-clone van de repos in /home/agent/Projects/<name>/ bij
# container-start (entrypoint.sh)
# 2. `git push` van agent feature-branches via HTTPS
# 3. `gh pr create` (auto_pr=true) — gh CLI leest GH_TOKEN uit env
# 2. `git push` van agent feature-branches naar Forgejo via HTTPS
#
# Genereer op github.com → Settings → Developer settings →
# Personal access tokens → Fine-grained tokens.
GH_TOKEN=ghp_vervang-mij
# `gh pr create` is uit de worker-flow verwijderd (PBI-86, T-1005);
# de GitHub-PR ontstaat via de handmatig getriggerde promote-Action
# in Forgejo.
#
# Genereer in Forgejo: avatar → Settings → Applications →
# Generate New Token; scope minimaal `write:repository`.
GH_TOKEN=vervang-mij
# Lijst (komma-gescheiden) van repos om vooraf te clonen naar
# ~agent/Projects/<name>. resolveRepoRoot in scrum4me-mcp valt
# automatisch terug op die conventie. Voeg meer toe als je nieuwe
# producten/repos toevoegt aan Scrum4Me.
GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp
# automatisch terug op die conventie. `<owner>/<repo>` zoals 'ie op
# Forgejo staat. Voeg meer toe als je nieuwe producten/repos toevoegt.
GH_PRECLONE_REPOS=janpeter/Scrum4Me,janpeter/scrum4me-mcp
# ----- Git commit-author -------------------------------------
# Verplicht — Vercel weigert deploys waarvan de commit-author email
@ -108,3 +110,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

153
CLAUDE.md
View file

@ -1,106 +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/<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
- 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-<id>` 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)
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).
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: <tail>)` 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 24 `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 <id>"`,
en ga door met de volgende job.
Voor blokkerende keuzes die niet uit het plan volgen: gebruik
`mcp__scrum4me__ask_user_question` met 24 `options` en `wait_seconds: 600`.
Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord
op vraag <id>")`. 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/<kind>/`).
- 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.

View file

@ -53,10 +53,15 @@ RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} \
# Clone zonder submodules — de Prisma-schema zit al gecommit in het repo.
# De vendor/scrum4me submodule is alleen nodig om het schema te updaten,
# niet om te builden. Pin via build-arg; default = main.
ARG MCP_GIT_REPO=https://github.com/madhura68/scrum4me-mcp.git
ARG MCP_GIT_REPO=https://git.jp-visser.nl/janpeter/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 \
@ -91,6 +96,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 +116,9 @@ 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 \
NODE_PATH=/opt/scrum4me-mcp/node_modules
EXPOSE 8080

View file

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

View file

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

View file

@ -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/<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

View file

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

View file

@ -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'
# 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,31 @@ 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
# RUN_LOG laat run-one-job.ts een jobs/<job_id>.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
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) — 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 \
--arg endedAt "$iteration_end" \

450
bin/run-one-job.ts Normal file
View file

@ -0,0 +1,450 @@
#!/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,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"]