Compare commits
34 commits
fix/state-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5529f3850d | |||
|
|
cb8f48d49e | ||
|
|
28ef6818a3 | ||
|
|
a051bb00d4 | ||
|
|
1a87bee280 | ||
|
|
c64c0278f2 | ||
|
|
794ad7faaa | ||
|
|
0b5a044ea5 | ||
|
|
7ec32c8def | ||
|
|
e8c4518abb | ||
|
|
38c0e5f103 | ||
|
|
2a1fb5677e | ||
|
|
095a277646 | ||
|
|
4d28e084dd | ||
|
|
51a7c5dd7b | ||
|
|
a6079892d7 | ||
|
|
b6bea1ecbb | ||
|
|
d01c8956d7 | ||
|
|
8fc39f7492 | ||
|
|
b189359672 | ||
|
|
318a071f11 | ||
|
|
cc9cb8a965 | ||
|
|
cf6969733c | ||
|
|
693ab50457 | ||
|
|
e4e0760b1b | ||
|
|
3e3d0e5b10 | ||
|
|
4b2241235e | ||
|
|
70cfe0374e | ||
|
|
266e1f2773 | ||
|
|
553a1ab551 | ||
|
|
6fb439cbd6 | ||
|
|
5c0f67f5ad | ||
|
|
3ca2829760 | ||
|
|
3f10d92ece |
15 changed files with 1100 additions and 145 deletions
16
.env.deploy.example
Normal file
16
.env.deploy.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# .env.deploy — config voor bin/deploy-to-nas.sh
|
||||||
|
# Kopieer naar .env.deploy en pas aan; staat in .gitignore.
|
||||||
|
|
||||||
|
# SSH-target voor de NAS. Moet werkend zijn met SSH-key (geen password).
|
||||||
|
NAS_HOST=admin@nas.local
|
||||||
|
|
||||||
|
# Pad op de NAS waar tarball + compose + .env terechtkomen.
|
||||||
|
# Default: /share/Agent/scrum4me-agent-runner
|
||||||
|
# NAS_REMOTE_DIR=/share/Agent/scrum4me-agent-runner
|
||||||
|
|
||||||
|
# Build-args (overrides). Standaard:
|
||||||
|
# MCP_GIT_REF=main — pin een commit in productie indien gewenst
|
||||||
|
# CLAUDE_CODE_VERSION=latest — pin een claude-code release indien gewenst
|
||||||
|
# AGENT_UID=1000, AGENT_GID=1000
|
||||||
|
# MCP_GIT_REF=main
|
||||||
|
# CLAUDE_CODE_VERSION=latest
|
||||||
34
.env.example
34
.env.example
|
|
@ -22,27 +22,29 @@ 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
|
||||||
|
|
||||||
# ----- GitHub credentials -----------------------------------
|
# ----- Forgejo credentials (PBI-86 hybride model) -----------
|
||||||
# Personal Access Token (fine-grained) met:
|
# Personal Access Token van je Forgejo-account met scope read+write
|
||||||
# - Repository access op madhura68/Scrum4Me + madhura68/scrum4me-mcp
|
# op de Scrum4Me-repos. Variabele heet historisch nog `GH_TOKEN`;
|
||||||
# - Permissions: Contents (read/write), Pull requests (read/write),
|
# in het hybride model bevat 'ie een Forgejo-PAT.
|
||||||
# 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 via HTTPS
|
# 2. `git push` van agent feature-branches naar Forgejo via HTTPS
|
||||||
# 3. `gh pr create` (auto_pr=true) — gh CLI leest GH_TOKEN uit env
|
|
||||||
#
|
#
|
||||||
# Genereer op github.com → Settings → Developer settings →
|
# `gh pr create` is uit de worker-flow verwijderd (PBI-86, T-1005);
|
||||||
# Personal access tokens → Fine-grained tokens.
|
# de GitHub-PR ontstaat via de handmatig getriggerde promote-Action
|
||||||
GH_TOKEN=ghp_vervang-mij
|
# 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
|
# 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. Voeg meer toe als je nieuwe
|
# automatisch terug op die conventie. `<owner>/<repo>` zoals 'ie op
|
||||||
# producten/repos toevoegt aan Scrum4Me.
|
# Forgejo staat. Voeg meer toe als je nieuwe producten/repos toevoegt.
|
||||||
GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp
|
GH_PRECLONE_REPOS=janpeter/Scrum4Me,janpeter/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
|
||||||
|
|
@ -108,3 +110,9 @@ AGENT_BACKOFF_MAX=300
|
||||||
AGENT_LOG_GZIP_AFTER_HOURS=24
|
AGENT_LOG_GZIP_AFTER_HOURS=24
|
||||||
# Hoeveel dagen ge-gzipte logs bewaren voor we ze verwijderen.
|
# Hoeveel dagen ge-gzipte logs bewaren voor we ze verwijderen.
|
||||||
AGENT_LOG_DELETE_AFTER_DAYS=30
|
AGENT_LOG_DELETE_AFTER_DAYS=30
|
||||||
|
|
||||||
|
# Claude CLI --output-format. Default 'stream-json' streamt de volledige
|
||||||
|
# event-stream (tool-calls, berichten) live naar de run-log; 'text' geeft
|
||||||
|
# alleen Claude's eind-samenvatting (terser, maar geen live-meekijken).
|
||||||
|
# stream-json maakt de run-log JSONL — gebruik jq of een viewer.
|
||||||
|
AGENT_CLAUDE_OUTPUT_FORMAT=stream-json
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
.env.deploy
|
||||||
*.env.local
|
*.env.local
|
||||||
|
|
||||||
# Local dev overrides (niet committen, per ontwikkelaar)
|
# Local dev overrides (niet committen, per ontwikkelaar)
|
||||||
|
|
|
||||||
138
CLAUDE.md
138
CLAUDE.md
|
|
@ -1,89 +1,85 @@
|
||||||
# CLAUDE.md — Scrum4Me NAS-runner
|
# CLAUDE.md — Scrum4Me NAS-runner
|
||||||
|
|
||||||
Je draait als headless worker op een QNAP NAS. Dit document beschrijft
|
Je draait als headless worker op een QNAP NAS (of lokale Docker). Dit document
|
||||||
je rol; het wordt automatisch geladen door `claude -p` vanuit
|
wordt automatisch geladen door `claude -p` vanuit `/opt/agent/` en geeft je de
|
||||||
`/opt/agent/`.
|
**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
|
## 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`
|
||||||
zullen jouw token tonen.
|
tonen jouw token.
|
||||||
- Je hebt **geen push-rechten**. Geen SSH-keys op deze container, geen
|
- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT)
|
||||||
`~/.gitconfig` met push-credentials. Lokale commits zijn welkom; pushen
|
of de `primary_worktree_path` (idea-jobs). Buiten die directory en
|
||||||
is iets wat de eindgebruiker zelf doet na review.
|
`/var/log/agent` heb je niets te zoeken.
|
||||||
- Je opereert binnen `/tmp/job-<id>` per job. Buiten die directory en
|
- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')`
|
||||||
buiten `/var/log/agent` heb je niets te zoeken.
|
aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`).
|
||||||
|
|
||||||
## Operationele loop (verplicht)
|
## Hardstop-regels (gelden ongeacht je kind)
|
||||||
|
|
||||||
Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"*
|
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor
|
||||||
of equivalent:
|
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.
|
||||||
|
|
||||||
1. Roep `mcp__scrum4me__wait_for_job` aan. Geen argumenten, geen wait-time
|
## Project-CLAUDE.md (in worktree)
|
||||||
tweaken — de tool blokt zelf tot 600 s.
|
|
||||||
2. Als er een job geclaimd wordt:
|
De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude
|
||||||
1. Roep `bash /opt/agent/bin/job-prepare.sh <job_id> <repo_url>` aan
|
automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de
|
||||||
via Bash. Output is het pad van de working tree.
|
Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die
|
||||||
2. `cd` naar dat pad.
|
bevat de ST-code-commit-stijl, lint/test/build-commands, en project-
|
||||||
3. Lees de project-CLAUDE.md (`./CLAUDE.md`) volledig — die bevat de
|
specifieke patronen.
|
||||||
coding-standards van dit project en is voor deze job bindend.
|
|
||||||
4. Voer het `implementation_plan` uit dat je van `wait_for_job` kreeg.
|
|
||||||
Volg de Commit Strategy uit de project-CLAUDE.md (commit per laag,
|
|
||||||
ST-code in de titel).
|
|
||||||
5. Voer de project-verificaties uit die de project-CLAUDE.md voorschrijft
|
|
||||||
(typisch `npm run lint && npm test && npm run build`).
|
|
||||||
6. **Niet pushen.** Lokaal committen op een feature-branch is goed.
|
|
||||||
7. Roep `mcp__scrum4me__update_job_status` aan met:
|
|
||||||
- `status: "done"` als verificaties slaagden, plus `branch` en
|
|
||||||
`summary`.
|
|
||||||
- `status: "failed"` met `error` als iets onomkeerbaar misging.
|
|
||||||
8. Roep `mcp__scrum4me__check_queue_empty` aan (geen args). Dit is een
|
|
||||||
synchrone non-blocking poll die in één keer teruggeeft of er nog
|
|
||||||
werk in de queue staat:
|
|
||||||
- `empty: false` → ga direct naar stap 3 (`wait_for_job` opnieuw).
|
|
||||||
- `empty: true` → batch is klaar; geef recap en exit. Geen extra
|
|
||||||
`wait_for_job`-call die 600 s blokt.
|
|
||||||
9. Roep `bash /opt/agent/bin/job-cleanup.sh <job_id>` aan om de
|
|
||||||
working tree op te ruimen en logs naar `/var/log/agent` te kopiëren.
|
|
||||||
3. Op basis van stap 8: bij `empty: false` opnieuw `wait_for_job`; bij
|
|
||||||
`empty: true` direct naar stap 4. Stop niet midden in de loop, vraag
|
|
||||||
niets.
|
|
||||||
4. Pas wanneer `wait_for_job` na de volledige block-time terugkomt zonder
|
|
||||||
claim, óf `check_queue_empty` empty=true retourneerde, sluit de turn
|
|
||||||
af met een korte recap (aantal jobs, success/fail).
|
|
||||||
|
|
||||||
## Foutscenario's
|
## Foutscenario's
|
||||||
|
|
||||||
- **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer
|
- **Verificatie faalt** (lint/test/build rood): roep
|
||||||
`update_job_status('failed', error=...)` en ga door met de volgende job.
|
`update_job_status('failed', error: <tail>)` aan en sluit af. Geen
|
||||||
Niet retry'en — als de cache stuk is, zal de volgende job ook falen en
|
automatische fix-attempts; de eindgebruiker beslist.
|
||||||
zal de wrapper merken dat we te veel fouten op rij hebben.
|
- **Verify-gate DIVERGENT**: roep `verify_task_against_plan` opnieuw aan
|
||||||
- **Verificatie faalt** (lint/test/build rood): rapporteer `failed` met
|
met een `summary` die de afwijking onderbouwt, óf rapporteer `failed`.
|
||||||
de tail van de output in `error`. Geen automatische fix-attempts; de
|
- **Onverwachte runtime-fout**: laat de exception propageren. De runner
|
||||||
eindgebruiker beslist of ze het plan aanpassen.
|
detecteert exit≠0 zonder `update_job_status` en doet rollbackClaim;
|
||||||
- **Onverwachte runtime-fout** in de tools: laat de exception propageren.
|
de wrapper-loop in run-agent.sh schrijft een run-log en herstart met
|
||||||
De wrapper-loop schrijft een run-log en herstart `claude -p` met backoff.
|
backoff.
|
||||||
|
|
||||||
## Vraag-antwoord-kanaal (M11)
|
## Vraag-antwoord-kanaal (M11)
|
||||||
|
|
||||||
Als het `implementation_plan` ambigu is op een keuze die niet uit de
|
Voor blokkerende keuzes die niet uit het plan volgen: gebruik
|
||||||
acceptance-criteria volgt: gebruik `mcp__scrum4me__ask_user_question`
|
`mcp__scrum4me__ask_user_question` met 2–4 `options` en `wait_seconds: 600`.
|
||||||
met een korte vraag plus 2–4 `options`. Geef `wait_seconds: 600` mee
|
Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord
|
||||||
zodat de tool blijft wachten. Als de timer afloopt zonder antwoord:
|
op vraag <id>")`. Niet gokken. Niet aannemen.
|
||||||
status `failed`, `error: "Wacht op gebruikersantwoord op vraag <id>"`,
|
|
||||||
en ga door met de volgende job.
|
|
||||||
|
|
||||||
Niet gokken. Niet aannemen.
|
## Verwijzingen
|
||||||
|
|
||||||
## Wat je NIET doet
|
- Per-kind workflows: zie de prompt die de runner je in `claude -p` meegeeft
|
||||||
|
(komt uit `scrum4me-mcp/src/prompts/<kind>/`).
|
||||||
- Geen `git push`, ook niet naar `origin/<branch>` van een feature-branch.
|
- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo.
|
||||||
- Geen `npm publish`, `vercel deploy`, of welke release-actie dan ook.
|
- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo.
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
24
Dockerfile
24
Dockerfile
|
|
@ -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.
|
# 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://github.com/madhura68/scrum4me-mcp.git
|
ARG MCP_GIT_REPO=https://git.jp-visser.nl/janpeter/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 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 \
|
${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 \
|
||||||
|
|
@ -91,6 +96,17 @@ 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 \
|
||||||
|
|
@ -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_LOG_DIR=/var/log/agent \
|
||||||
AGENT_REPO_CACHE=/var/cache/repos \
|
AGENT_REPO_CACHE=/var/cache/repos \
|
||||||
AGENT_JOB_ROOT=/tmp \
|
AGENT_JOB_ROOT=/tmp \
|
||||||
AGENT_HEALTH_PORT=8080
|
AGENT_HEALTH_PORT=8080 \
|
||||||
|
SCRUM4ME_MCP_DIR=/opt/scrum4me-mcp \
|
||||||
|
NODE_PATH=/opt/scrum4me-mcp/node_modules
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
|
||||||
312
README.md
312
README.md
|
|
@ -13,7 +13,7 @@ nooit zelf.
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ container: agent-runner ────────────────────────────────┐ │
|
│ ┌─ container: agent-runner ────────────────────────────────┐ │
|
||||||
│ │ PID 1: tini → run-agent.sh (daemon-loop) │ │
|
│ │ PID 1: tini → run-agent.sh (daemon-loop) │ │
|
||||||
│ │ ├─ health-server.js (8080 → host) │ │
|
│ │ ├─ health-server.js (8080 → host 18080) │ │
|
||||||
│ │ └─ claude -p (per-batch, met MCP via stdio) │ │
|
│ │ └─ claude -p (per-batch, met MCP via stdio) │ │
|
||||||
│ │ └─ scrum4me-mcp → Neon Postgres │ │
|
│ │ └─ scrum4me-mcp → Neon Postgres │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
|
|
@ -52,16 +52,30 @@ fouten.
|
||||||
| `bin/check-tokens.sh` | Pre-flight: API-token, OAuth-token, DB-bereikbaarheid |
|
| `bin/check-tokens.sh` | Pre-flight: API-token, OAuth-token, DB-bereikbaarheid |
|
||||||
| `bin/job-prepare.sh` | Per-job: bare-fetch + clone-via-reference naar `/tmp/job-<id>` |
|
| `bin/job-prepare.sh` | Per-job: bare-fetch + clone-via-reference naar `/tmp/job-<id>` |
|
||||||
| `bin/job-cleanup.sh` | Per-job: logs naar `/var/log`, working tree weg |
|
| `bin/job-cleanup.sh` | Per-job: logs naar `/var/log`, working tree weg |
|
||||||
| `bin/health-server.js` | HTTP-endpoint op 8080 dat state.json en marker-files leest |
|
| `bin/health-server.js` | HTTP-endpoint op 8080 (intern) dat state.json en marker-files leest |
|
||||||
| `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden |
|
| `bin/rotate-logs.sh` | Compress/cleanup van oude `.log`-bestanden |
|
||||||
| `.env.example` | Alle env-vars met uitleg |
|
| `.env.example` | Alle env-vars met uitleg |
|
||||||
|
|
||||||
## Vereisten op de NAS
|
## Vereisten op de NAS
|
||||||
|
|
||||||
- Container Station 2+ (Docker compose v2)
|
- Container Station 2+ (Docker compose v2)
|
||||||
- Drie shares aangemaakt: `/share/Agent/cache`, `/share/Agent/logs`, `/share/Agent/state`
|
- **`Agent` als QTS Shared Folder** op een echte volume (bv. `CACHEDEV1_DATA`).
|
||||||
- Of één share `/share/Agent` waaronder de drie subdirs vallen
|
Niet een `mkdir /share/Agent` — `/share` zelf is een 16 MB tmpfs en handmatige
|
||||||
- Internet-uitgang naar `api.anthropic.com`, `github.com`, je Neon-host, `registry.npmjs.org`
|
directories overleven geen reboot. Aanmaken via Control Panel → Privilege →
|
||||||
|
Shared Folders → Create. QTS legt dan automatisch de symlink
|
||||||
|
`/share/Agent → /share/CACHEDEV1_DATA/Agent`.
|
||||||
|
- Drie subdirs onder die share: `/share/Agent/cache`, `/share/Agent/logs`,
|
||||||
|
`/share/Agent/state`. Aanmaken via File Station of via SSH na share-creatie.
|
||||||
|
- Internet-uitgang naar `api.anthropic.com`, `git.jp-visser.nl` (Forgejo HTTPS-clone/push), `cli.github.com` (build-time voor de gh CLI), je Neon-host, `registry.npmjs.org`.
|
||||||
|
|
||||||
|
> **Verifieer** vóór je deployt dat `/share/Agent` echt op disk staat:
|
||||||
|
> ```bash
|
||||||
|
> ssh admin@<nas> 'ls -la /share/ | grep Agent; df -h /share/Agent'
|
||||||
|
> ```
|
||||||
|
> Verwacht een symlink (`l...Agent -> /share/CACHEDEV1_DATA/Agent`) en een
|
||||||
|
> df-uitvoer met TB-grootte op `cachedev1`/`cachedev2`. Als je hier `tmpfs 16M`
|
||||||
|
> ziet, is de share geen geregistreerde QTS Shared Folder en zal elke transfer
|
||||||
|
> >16 MB falen met `scp: write remote ... Failure`.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|
@ -71,18 +85,21 @@ 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 → github.com → Settings → Developer settings →
|
# d. GH_TOKEN → Forgejo → avatar → Settings →
|
||||||
# Personal access tokens → Fine-grained.
|
# Applications → Generate New Token; scope
|
||||||
# Repository access op madhura68/Scrum4Me +
|
# minimaal `write:repository` op de twee
|
||||||
# madhura68/scrum4me-mcp; Permissions:
|
# repos (janpeter/Scrum4Me + janpeter/
|
||||||
# Contents (RW), Pull requests (RW),
|
# scrum4me-mcp). Wordt gebruikt voor clone
|
||||||
# Metadata (R). Wordt gebruikt voor clone,
|
# en push naar Forgejo. PBI-86 (hybride
|
||||||
# push en `gh pr create` (auto_pr).
|
# 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
|
# 2. Repo op de NAS plaatsen
|
||||||
ssh admin@nas
|
ssh admin@nas
|
||||||
cd /share/Agent
|
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
|
cd scrum4me-agent-runner
|
||||||
|
|
||||||
# 3. Env aanmaken
|
# 3. Env aanmaken
|
||||||
|
|
@ -95,10 +112,158 @@ docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 5. Verifiëren
|
# 5. Verifiëren
|
||||||
curl http://nas.local:8080/health
|
curl http://nas.local:18080/health
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **QNAP-port:** host-poort 8080 is bezet door de QTS-webinterface; daarom
|
||||||
|
> mapt deze stack standaard `18080:8080`. Override via
|
||||||
|
> `AGENT_HEALTH_PORT_HOST` in `.env` als je een andere host-poort wilt.
|
||||||
|
|
||||||
|
## Snelle redeploy — `bin/deploy-to-nas.sh`
|
||||||
|
|
||||||
|
Voor een **bestaande deploy** die je opnieuw wil bouwen + deployen
|
||||||
|
(bijvoorbeeld na een merge in `scrum4me-mcp` of een aanpassing aan
|
||||||
|
`CLAUDE.md`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Eenmalig: NAS-target instellen
|
||||||
|
cp .env.deploy.example .env.deploy
|
||||||
|
vi .env.deploy # zet NAS_HOST=admin@<nas>
|
||||||
|
|
||||||
|
# Daarna: één commando voor de hele cyclus
|
||||||
|
bin/deploy-to-nas.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Het script doet:
|
||||||
|
|
||||||
|
1. `docker buildx build --platform linux/amd64 --load`
|
||||||
|
2. `docker save | gzip → scrum4me-agent-runner-amd64.tar.gz`
|
||||||
|
3. `scp` van tarball + `docker-compose.yml` + (eerste keer) `.env` naar NAS
|
||||||
|
4. `ssh` op NAS: `docker load` + sanity-check op `.env` + `docker compose up -d --force-recreate`
|
||||||
|
5. `docker compose logs -f` — lokaal-volgbaar terwijl pre-flight + eerste batch starten
|
||||||
|
|
||||||
|
`.env` op de NAS wordt **niet** overschreven als 'ie er al staat. Bij een
|
||||||
|
verse NAS-installatie wordt 'ie wél geüpload + ge-sed't (NAS_BASE,
|
||||||
|
AGENT_UID, etc.). Voor de **eerste deploy** of een schoon volume zie de
|
||||||
|
volledige procedure hieronder.
|
||||||
|
|
||||||
|
## Deploy — cross-build vanaf Mac (Apple Silicon → amd64-NAS)
|
||||||
|
|
||||||
|
Alternatief voor de in-place build hierboven. Bouw de image op je Mac voor
|
||||||
|
`linux/amd64`, schrijf 'm naar een tarball, transfer naar de NAS en laad daar.
|
||||||
|
Handig als de NAS te langzaam is om te builden (npm install op een N5095 met
|
||||||
|
NAS-storage is traag) of als je geen `git push` wilt voor elke iteratie.
|
||||||
|
|
||||||
|
**Vóór je begint:** controleer dat `/share/Agent` een echte QTS Shared Folder is
|
||||||
|
(zie [Vereisten op de NAS](#vereisten-op-de-nas)). Dat is de meest voorkomende
|
||||||
|
val.
|
||||||
|
|
||||||
|
### 1. Tokens en `.env` op je Mac
|
||||||
|
|
||||||
|
Zelfde tokens als in [Deploy](#deploy). Hou er rekening mee dat je **twee
|
||||||
|
`.env`-bestanden** kunt willen: één voor lokaal Mac-testen
|
||||||
|
(`AGENT_PLATFORM=linux/arm64`, `AGENT_UID=501`, paths onder `/Users/...`) en
|
||||||
|
één voor NAS-runtime. De NAS-versie wordt bij stap 4 ge-scp'd en met `sed`
|
||||||
|
geschikt gemaakt.
|
||||||
|
|
||||||
|
### 2. Image bouwen voor amd64
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/<jij>/Development/scrum4me-docker
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg MCP_GIT_REF=main \
|
||||||
|
--build-arg CLAUDE_CODE_VERSION=latest \
|
||||||
|
--build-arg AGENT_UID=1000 \
|
||||||
|
--build-arg AGENT_GID=1000 \
|
||||||
|
-t scrum4me-agent-runner:local \
|
||||||
|
--load \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
`AGENT_UID/GID=1000` zijn de **NAS-UIDs**, niet je Mac-UIDs (vaak `501/20`).
|
||||||
|
Bij verkeerde UIDs kan de container niet schrijven naar de bind-mounts.
|
||||||
|
|
||||||
|
Verifieer architectuur:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker image inspect scrum4me-agent-runner:local --format '{{.Architecture}}'
|
||||||
|
# verwacht: amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Image naar tarball
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save scrum4me-agent-runner:local | gzip > scrum4me-agent-runner-amd64.tar.gz
|
||||||
|
shasum -a 256 scrum4me-agent-runner-amd64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
De tarball is ~580 MB voor de huidige image-size. Hij staat in `.gitignore`,
|
||||||
|
dus geen risico op accidenteel committen.
|
||||||
|
|
||||||
|
### 4. NAS voorbereiden + bestanden transferren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env naar de NAS (de Mac-versie; we patchen path-velden zo direct)
|
||||||
|
scp .env admin@<nas>:/share/Agent/scrum4me-agent-runner/.env
|
||||||
|
|
||||||
|
# image-tarball + bijgewerkte compose
|
||||||
|
scp scrum4me-agent-runner-amd64.tar.gz docker-compose.yml package.json README.md \
|
||||||
|
admin@<nas>:/share/Agent/scrum4me-agent-runner/
|
||||||
|
```
|
||||||
|
|
||||||
|
Zet de NAS-specifieke waarden in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@<nas> "
|
||||||
|
source /etc/profile
|
||||||
|
cd /share/Agent/scrum4me-agent-runner
|
||||||
|
sed -i \
|
||||||
|
-e 's|^NAS_BASE=.*|NAS_BASE=/share/Agent|' \
|
||||||
|
-e 's|^AGENT_BASE=.*|AGENT_BASE=/share/Agent|' \
|
||||||
|
-e 's|^AGENT_PLATFORM=.*|AGENT_PLATFORM=linux/amd64|' \
|
||||||
|
-e 's|^AGENT_UID=.*|AGENT_UID=1000|' \
|
||||||
|
-e 's|^AGENT_GID=.*|AGENT_GID=1000|' \
|
||||||
|
-e 's|^AGENT_HEALTH_PORT_HOST=.*|AGENT_HEALTH_PORT_HOST=18080|' \
|
||||||
|
.env
|
||||||
|
chmod 600 .env
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`source /etc/profile` is verplicht** in non-interactieve ssh op QNAP. Zonder
|
||||||
|
> die regel staat `docker` niet op `$PATH` (Container Station's binary zit onder
|
||||||
|
> `/share/CACHEDEV*_DATA/.qpkg/container-station/...`) en faalt elk
|
||||||
|
> `docker`-commando met `command not found`.
|
||||||
|
|
||||||
|
### 5. Image laden + container recreaten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@<nas> "
|
||||||
|
source /etc/profile
|
||||||
|
set -e
|
||||||
|
cd /share/Agent/scrum4me-agent-runner
|
||||||
|
|
||||||
|
# checksum-verificatie (optioneel maar aan te raden)
|
||||||
|
sha256sum scrum4me-agent-runner-amd64.tar.gz
|
||||||
|
|
||||||
|
# image laden — overschrijft bestaande :local tag
|
||||||
|
gunzip -c scrum4me-agent-runner-amd64.tar.gz | docker load
|
||||||
|
|
||||||
|
# container recreate; --no-build voorkomt onbedoelde NAS-side build
|
||||||
|
docker compose up -d --no-build --force-recreate agent
|
||||||
|
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS http://localhost:18080/health | head -c 800
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verwacht in de health-output `cache_free_bytes` met een groot getal
|
||||||
|
(TB-orde) — dat is je signaal dat `/var/cache` op echte disk zit.
|
||||||
|
Een waarde rond `16 MB` (`16777216`) betekent dat je per ongeluk
|
||||||
|
nog steeds op tmpfs draait.
|
||||||
|
|
||||||
## Updaten (handmatig, bewust)
|
## Updaten (handmatig, bewust)
|
||||||
|
|
||||||
`SCRUM4ME_TOKEN` of `CLAUDE_CODE_OAUTH_TOKEN` rouleer je via een rebuild:
|
`SCRUM4ME_TOKEN` of `CLAUDE_CODE_OAUTH_TOKEN` rouleer je via een rebuild:
|
||||||
|
|
@ -136,7 +301,7 @@ docker exec scrum4me-agent df -h /var/cache
|
||||||
|
|
||||||
## Health-endpoint
|
## Health-endpoint
|
||||||
|
|
||||||
`GET http://<nas>:8080/health` retourneert:
|
`GET http://<nas>:18080/health` retourneert:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -164,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:
|
`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://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):
|
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`
|
||||||
|
|
@ -175,8 +341,118 @@ 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` (voor producten met `auto_pr=true`)
|
slaagt zonder prompt. **`gh pr create` is in PBI-86 (T-1005) verwijderd
|
||||||
gebruikt dezelfde `GH_TOKEN` via de `gh` CLI's standaard env-detect.
|
uit de worker-flow** — de GitHub-PR ontstaat via een handmatig
|
||||||
|
getriggerde promote-Action in Forgejo (zie de Scrum4Me-repo
|
||||||
|
`docs/runbooks/forgejo-hybrid-flow.md`).
|
||||||
|
|
||||||
|
## Veelvoorkomende issues
|
||||||
|
|
||||||
|
Verzameld uit echte deploy-sessies op een TS-664 met QTS 5.x. Eerst checken
|
||||||
|
voor je gaat tweaken.
|
||||||
|
|
||||||
|
### `/share/Agent` is `tmpfs 16M` — scp faalt op grote files
|
||||||
|
|
||||||
|
**Symptoom:**
|
||||||
|
```
|
||||||
|
scp: write remote ".../scrum4me-agent-runner-amd64.tar.gz": Failure
|
||||||
|
```
|
||||||
|
Eerst 100 % bij kleine files, dan blijvend "Failure" zodra een file de tmpfs
|
||||||
|
vol maakt.
|
||||||
|
|
||||||
|
**Oorzaak:** `/share/Agent` is geen geregistreerde QTS Shared Folder maar een
|
||||||
|
gewone directory in de root-tmpfs. Na elke reboot is alle inhoud weg en is
|
||||||
|
`/share/Agent` slechts een entry in de 16 MB `/share`-tmpfs.
|
||||||
|
|
||||||
|
**Fix:** maak `Agent` aan via Control Panel → Privilege → Shared Folders →
|
||||||
|
Create. Daarna verschijnt automatisch `lrwxrwxrwx /share/Agent → /share/CACHEDEV1_DATA/Agent`.
|
||||||
|
Als die symlink ontbreekt omdat er al een tmpfs-directory `/share/Agent` staat:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@<nas> "
|
||||||
|
source /etc/profile
|
||||||
|
docker stop scrum4me-agent 2>/dev/null
|
||||||
|
docker rm scrum4me-agent 2>/dev/null
|
||||||
|
rm -rf /share/Agent
|
||||||
|
ln -s /share/CACHEDEV1_DATA/Agent /share/Agent
|
||||||
|
mkdir -p /share/Agent/{cache,logs,state,scrum4me-agent-runner}
|
||||||
|
ls -la /share/ | grep Agent # moet nu een symlink tonen
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`rm -rf /share/Agent` is veilig** zolang `/share/Agent` nog tmpfs is —
|
||||||
|
> alle content stond in RAM en zou de volgende reboot toch verdwenen zijn.
|
||||||
|
> Maar verifieer eerst met `df -h /share/Agent` dat 't echt tmpfs is.
|
||||||
|
|
||||||
|
### `docker: command not found` in non-interactieve ssh
|
||||||
|
|
||||||
|
**Symptoom:**
|
||||||
|
```bash
|
||||||
|
ssh admin@<nas> 'docker ps'
|
||||||
|
# sh: line 1: docker: command not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Oorzaak:** QNAP's `admin`-user heeft `docker` op `$PATH` via
|
||||||
|
`/etc/profile.d/*.sh` van Container Station. Login-shells laden die scripts;
|
||||||
|
non-interactieve `ssh user@host 'cmd'` doet dat **niet**.
|
||||||
|
|
||||||
|
**Fix:** `source /etc/profile` aan het begin van je remote command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@<nas> "source /etc/profile && docker ps"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `yaml: control characters are not allowed`
|
||||||
|
|
||||||
|
**Symptoom:**
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
# yaml: control characters are not allowed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Oorzaak:** een eerdere `scp` van `docker-compose.yml` faalde halverwege en
|
||||||
|
liet een file met NUL-bytes / partial writes achter. De file-grootte klopt
|
||||||
|
maar de inhoud is corrupt.
|
||||||
|
|
||||||
|
**Fix:** scp opnieuw vanaf je werkstation. Voor het stoppen van een al-
|
||||||
|
draaiende container heb je de yml niet nodig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop scrum4me-agent && docker rm scrum4me-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Daarna `scp docker-compose.yml admin@<nas>:/share/Agent/scrum4me-agent-runner/`
|
||||||
|
en pas dan `docker compose up -d --no-build --force-recreate`.
|
||||||
|
|
||||||
|
### QTS-console-menu Python-traceback bij ssh-commando
|
||||||
|
|
||||||
|
**Symptoom:** lange Python-traceback over `consolemenu_q/prompt_utils.py:31`
|
||||||
|
en `Inappropriate ioctl for device`, gevolgd door je daadwerkelijke output.
|
||||||
|
|
||||||
|
**Oorzaak:** QTS' admin-shell start een Python-TUI (qts-console-menu) die
|
||||||
|
crasht zonder echte TTY. Niet-fataal — je commando wordt alsnog gerund.
|
||||||
|
|
||||||
|
**Fix:** negeer de traceback. Als je 'm écht weg wilt, gebruik
|
||||||
|
`ssh -tt admin@<nas> '...'` voor een geforceerde pseudo-TTY, maar pas op:
|
||||||
|
sommige scripts hangen daarop omdat ze interactie verwachten.
|
||||||
|
|
||||||
|
### `.env` is weg na `rm -rf /share/Agent`
|
||||||
|
|
||||||
|
**Symptoom:** na het opruimen van een tmpfs-`/share/Agent` weigert
|
||||||
|
`docker compose up`:
|
||||||
|
```
|
||||||
|
env file /share/Agent/scrum4me-agent-runner/.env not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Oorzaak:** `.env` zit in `.gitignore` en wordt nooit door `git pull` of `scp
|
||||||
|
.` automatisch teruggezet. Bij het wegnuken van een corrupte share verdwijnt-ie
|
||||||
|
mee.
|
||||||
|
|
||||||
|
**Fix:** scp 'm vanaf je werkstation, en patch path-velden op de NAS-zijde
|
||||||
|
(zie [Deploy — cross-build](#deploy--cross-build-vanaf-mac-apple-silicon--amd64-nas)
|
||||||
|
stap 4). Zorg dat je `.env` op je werkstation **alle** secrets bevat — vooral
|
||||||
|
`SCRUM4ME_TOKEN`, dat in een Mac-only `.env` makkelijk ontbreekt omdat
|
||||||
|
lokale tests soms zonder Scrum4Me-API draaien.
|
||||||
|
|
||||||
## Bekende grenzen
|
## Bekende grenzen
|
||||||
|
|
||||||
|
|
|
||||||
112
bin/deploy-to-nas.sh
Executable file
112
bin/deploy-to-nas.sh
Executable file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy-to-nas.sh — Mac → NAS cross-build deploy in één commando.
|
||||||
|
#
|
||||||
|
# Voert in volgorde uit:
|
||||||
|
# 1. docker buildx build (linux/amd64, --load)
|
||||||
|
# 2. docker save | gzip → scrum4me-agent-runner-amd64.tar.gz
|
||||||
|
# 3. scp tarball + docker-compose.yml + (eerste keer) .env naar NAS
|
||||||
|
# 4. ssh: docker load + sed-patch .env paths (eerste keer) + compose up --force-recreate
|
||||||
|
# 5. ssh: docker compose logs -f voor verificatie
|
||||||
|
#
|
||||||
|
# Gebruik:
|
||||||
|
# bin/deploy-to-nas.sh # gebruikt env vars uit .env.deploy
|
||||||
|
# NAS_HOST=admin@nas bin/deploy-to-nas.sh
|
||||||
|
#
|
||||||
|
# Vereisten:
|
||||||
|
# - docker buildx geïnstalleerd
|
||||||
|
# - .env aanwezig in repo-root (deze wordt naar NAS gestuurd op eerste run)
|
||||||
|
# - .env.deploy met NAS_HOST + NAS_REMOTE_DIR (optioneel — anders prompted)
|
||||||
|
# - SSH-key access naar de NAS (geen password-prompts)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# ----- config -----------------------------------------------------------
|
||||||
|
if [[ -f .env.deploy ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env.deploy
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${NAS_HOST:?NAS_HOST not set — bv. admin@nas.local. Zet 'm in .env.deploy of als env-var.}"
|
||||||
|
: "${NAS_REMOTE_DIR:=/share/Agent/scrum4me-agent-runner}"
|
||||||
|
: "${IMAGE_TAG:=scrum4me-agent-runner:local}"
|
||||||
|
: "${TARBALL:=scrum4me-agent-runner-amd64.tar.gz}"
|
||||||
|
: "${MCP_GIT_REF:=main}"
|
||||||
|
: "${CLAUDE_CODE_VERSION:=latest}"
|
||||||
|
: "${AGENT_UID:=1000}"
|
||||||
|
: "${AGENT_GID:=1000}"
|
||||||
|
|
||||||
|
log() { echo "[deploy-to-nas] $*"; }
|
||||||
|
|
||||||
|
# ----- pre-flight -------------------------------------------------------
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
log "FAIL: .env ontbreekt in repo-root. Maak 'm aan via: cp .env.example .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 1. buildx --------------------------------------------------------
|
||||||
|
log "1/5 docker buildx build (linux/amd64, MCP_GIT_REF=$MCP_GIT_REF)"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg "MCP_GIT_REF=${MCP_GIT_REF}" \
|
||||||
|
--build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \
|
||||||
|
--build-arg "AGENT_UID=${AGENT_UID}" \
|
||||||
|
--build-arg "AGENT_GID=${AGENT_GID}" \
|
||||||
|
-t "$IMAGE_TAG" \
|
||||||
|
--load \
|
||||||
|
.
|
||||||
|
|
||||||
|
# ----- 2. tarball -------------------------------------------------------
|
||||||
|
log "2/5 docker save | gzip → $TARBALL"
|
||||||
|
docker save "$IMAGE_TAG" | gzip > "$TARBALL"
|
||||||
|
ls -lh "$TARBALL"
|
||||||
|
|
||||||
|
# ----- 3. scp -----------------------------------------------------------
|
||||||
|
log "3/5 scp tarball + compose naar $NAS_HOST:$NAS_REMOTE_DIR"
|
||||||
|
ssh "$NAS_HOST" "mkdir -p '$NAS_REMOTE_DIR'"
|
||||||
|
|
||||||
|
# Check of er al een .env op de NAS staat. Zo niet: stuur de Mac-versie en
|
||||||
|
# patch hem. Zo wel: laat 'm met rust (kan NAS-specifiek aangepast zijn).
|
||||||
|
if ssh "$NAS_HOST" "test -f '$NAS_REMOTE_DIR/.env'" 2>/dev/null; then
|
||||||
|
log " (.env bestaat al op NAS — niet overschreven)"
|
||||||
|
else
|
||||||
|
log " geen .env op NAS, kopiëren + patchen"
|
||||||
|
scp .env "$NAS_HOST:$NAS_REMOTE_DIR/.env"
|
||||||
|
ssh "$NAS_HOST" "
|
||||||
|
cd '$NAS_REMOTE_DIR'
|
||||||
|
chmod 600 .env
|
||||||
|
sed -i \\
|
||||||
|
-e 's|^NAS_BASE=.*|NAS_BASE=/share/Agent|' \\
|
||||||
|
-e 's|^AGENT_BASE=.*|AGENT_BASE=/share/Agent|' \\
|
||||||
|
-e 's|^AGENT_PLATFORM=.*|AGENT_PLATFORM=linux/amd64|' \\
|
||||||
|
-e 's|^AGENT_UID=.*|AGENT_UID=${AGENT_UID}|' \\
|
||||||
|
-e 's|^AGENT_GID=.*|AGENT_GID=${AGENT_GID}|' \\
|
||||||
|
-e 's|^AGENT_HEALTH_PORT_HOST=.*|AGENT_HEALTH_PORT_HOST=18080|' \\
|
||||||
|
.env
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
scp "$TARBALL" docker-compose.yml package.json README.md \
|
||||||
|
"$NAS_HOST:$NAS_REMOTE_DIR/"
|
||||||
|
|
||||||
|
# ----- 4. load + restart ------------------------------------------------
|
||||||
|
log "4/5 docker load + compose up --force-recreate op NAS"
|
||||||
|
ssh "$NAS_HOST" "
|
||||||
|
set -eu
|
||||||
|
source /etc/profile
|
||||||
|
cd '$NAS_REMOTE_DIR'
|
||||||
|
|
||||||
|
# Sanity: env-vars die check-tokens.sh nodig heeft
|
||||||
|
grep -qE '^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=' .env || { echo 'FAIL: anthropic credential ontbreekt in .env'; exit 1; }
|
||||||
|
grep -qE '^SCRUM4ME_TOKEN=' .env || { echo 'FAIL: SCRUM4ME_TOKEN ontbreekt in .env'; exit 1; }
|
||||||
|
grep -qE '^DATABASE_URL=' .env || { echo 'FAIL: DATABASE_URL ontbreekt in .env'; exit 1; }
|
||||||
|
|
||||||
|
gunzip -c '$TARBALL' | docker load
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
"
|
||||||
|
|
||||||
|
# ----- 5. tail logs ------------------------------------------------------
|
||||||
|
log "5/5 docker compose logs (Ctrl-C om te stoppen)"
|
||||||
|
ssh "$NAS_HOST" "cd '$NAS_REMOTE_DIR' && docker compose logs -f --tail=50"
|
||||||
|
|
@ -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: "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" \
|
SLUG=$(echo "$REPO_URL" \
|
||||||
| sed -E 's#^.*[:/]([^/]+/[^/]+?)(\.git)?/?$#\1#' \
|
| sed -E 's#^.*[:/]([^/]+/[^/]+?)(\.git)?/?$#\1#' \
|
||||||
| tr '/' '_')
|
| tr '/' '_')
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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}@github.com" "$CREDS_FILE" 2>/dev/null; then
|
if [[ ! -f "$CREDS_FILE" ]] || ! grep -q "oauth2:${GH_TOKEN}@git.jp-visser.nl" "$CREDS_FILE" 2>/dev/null; then
|
||||||
printf 'https://oauth2:%s@github.com\n' "$GH_TOKEN" > "$CREDS_FILE"
|
printf 'https://oauth2:%s@git.jp-visser.nl\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://github.com/${repo}.git" "$target" \
|
git clone --quiet "https://git.jp-visser.nl/${repo}.git" "$target" \
|
||||||
|| { log "ERROR: clone failed for ${repo}"; continue; }
|
|| { log "ERROR: clone failed for ${repo}"; continue; }
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@
|
||||||
#
|
#
|
||||||
# Strategie:
|
# Strategie:
|
||||||
# - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal)
|
# - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal)
|
||||||
# - Loop: claude -p met seed-prompt
|
# - Loop: tsx /opt/agent/bin/run-one-job.ts (één geclaimde job per iteratie)
|
||||||
# - Exit 0 → de queue was leeg, sleep kort, herhaal
|
# - 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
|
# - 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
|
||||||
|
|
||||||
|
|
@ -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/rotate-logs.sh || true
|
||||||
/opt/agent/bin/log-cleanup.sh || true
|
/opt/agent/bin/log-cleanup.sh || true
|
||||||
|
|
||||||
# ----- seed prompt ------------------------------------------------------
|
# Geen seed-prompt en geen ALLOWED_TOOLS-string meer: per-job CLI-flags
|
||||||
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.'
|
# (incl. --model, --permission-mode, --effort, --allowedTools en de
|
||||||
|
# kind-specifieke prompt) worden door run-one-job.ts gebouwd uit
|
||||||
# Tools-allowlist: alle MCP-tools die scrum4me-mcp aanbiedt + standaard
|
# JobConfig (resolved via PBI-67's resolveJobConfig).
|
||||||
# file/bash-tools. Geen WebFetch, geen WebSearch — de agent heeft die
|
|
||||||
# niet nodig en uitsluiting verkleint het surface.
|
|
||||||
ALLOWED_TOOLS='Read,Edit,Write,Bash,Grep,Glob,mcp__scrum4me__health,mcp__scrum4me__list_products,mcp__scrum4me__get_claude_context,mcp__scrum4me__wait_for_job,mcp__scrum4me__check_queue_empty,mcp__scrum4me__update_job_status,mcp__scrum4me__update_task_status,mcp__scrum4me__update_task_plan,mcp__scrum4me__log_implementation,mcp__scrum4me__log_test_result,mcp__scrum4me__log_commit,mcp__scrum4me__create_pbi,mcp__scrum4me__create_story,mcp__scrum4me__create_task,mcp__scrum4me__create_todo,mcp__scrum4me__ask_user_question,mcp__scrum4me__get_question_answer,mcp__scrum4me__list_open_questions,mcp__scrum4me__cancel_question'
|
|
||||||
|
|
||||||
CONSEC_FAILURES=0
|
CONSEC_FAILURES=0
|
||||||
BACKOFF=${AGENT_BACKOFF_START}
|
BACKOFF=${AGENT_BACKOFF_START}
|
||||||
|
|
@ -60,32 +62,31 @@ 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 batch (log: ${run_log})"
|
log "starting iteration (log: ${run_log})"
|
||||||
|
|
||||||
# claude -p met onze MCP-config en allowlist.
|
# Eén iteratie = één geclaimde job (of "geen job" → exit 0). De runner
|
||||||
# cwd = /opt/agent zodat onze CLAUDE.md auto-geladen wordt.
|
# claimt zelf via tryClaimJob, leest JobConfig (PBI-67), bouwt de
|
||||||
#
|
# 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
|
||||||
claude -p "${SEED_PROMPT}" \
|
# RUN_LOG laat run-one-job.ts een jobs/<job_id>.log symlink leggen naar
|
||||||
--mcp-config /opt/agent/mcp-config.json \
|
# dit run-log, zodat de output van een job op job-id vindbaar is.
|
||||||
--allowedTools "${ALLOWED_TOOLS}" \
|
RUN_LOG="${run_log}" tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1
|
||||||
--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: parse stderr/stdout op bekende strings.
|
# Token-expiry detectie: run-one-job.ts retourneert exit 3 wanneer het
|
||||||
if grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then
|
# bekende auth-error-strings in Claude's output ziet. We checken óók de
|
||||||
log "AUTH FAILURE detected in run log — marking TOKEN_EXPIRED"
|
# 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"
|
touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED"
|
||||||
write_state "$(jq -n \
|
write_state "$(jq -n \
|
||||||
--arg endedAt "$iteration_end" \
|
--arg endedAt "$iteration_end" \
|
||||||
|
|
|
||||||
450
bin/run-one-job.ts
Normal file
450
bin/run-one-job.ts
Normal 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)
|
||||||
|
})
|
||||||
70
bin/worker-quota-probe.sh
Executable file
70
bin/worker-quota-probe.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# worker-quota-probe.sh — pre-flight Anthropic rate-limit-quota meting.
|
||||||
|
#
|
||||||
|
# Doet een lichtgewicht /v1/messages-call (1 token max) en parsed de
|
||||||
|
# `anthropic-ratelimit-*-tokens`-headers. Output is JSON op stdout.
|
||||||
|
#
|
||||||
|
# Output (success):
|
||||||
|
# {"remaining": 9982000, "limit": 10000000, "pct": 99, "reset_at_iso": "..."}
|
||||||
|
#
|
||||||
|
# Output (fail):
|
||||||
|
# {"error": "...", "http_status": NNN}
|
||||||
|
#
|
||||||
|
# Exit-codes: 0 success, 1 op fout. De worker-loop gebruikt de pct + reset
|
||||||
|
# om te beslissen of-ie wait_for_job mag aanroepen (gate via min_quota_pct
|
||||||
|
# uit get_worker_settings).
|
||||||
|
#
|
||||||
|
# Gebruikt env-var ANTHROPIC_API_KEY of CLAUDE_CODE_OAUTH_TOKEN. OAuth-tokens
|
||||||
|
# worden door de Anthropic API herkend als Bearer; de header-set is dezelfde.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
KEY="${ANTHROPIC_API_KEY:-${CLAUDE_CODE_OAUTH_TOKEN:-}}"
|
||||||
|
if [[ -z "$KEY" ]]; then
|
||||||
|
echo '{"error":"no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN set"}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HDR_FILE=$(mktemp /tmp/quota-probe.XXXXXX.headers)
|
||||||
|
trap 'rm -f "$HDR_FILE"' EXIT
|
||||||
|
|
||||||
|
# Minimale call: claude-haiku-4-5 met 1 max_tokens en een single-char input.
|
||||||
|
# Kost ~1 outputtoken; doel is alleen de rate-limit-headers binnen te halen.
|
||||||
|
HTTP_STATUS=$(curl -sS -o /dev/null \
|
||||||
|
-D "$HDR_FILE" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer ${KEY}" \
|
||||||
|
-H "anthropic-version: 2023-06-01" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"."}]}' \
|
||||||
|
https://api.anthropic.com/v1/messages 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
# Parse rate-limit headers (case-insensitive grep).
|
||||||
|
REMAINING=$(grep -i '^anthropic-ratelimit-output-tokens-remaining:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
LIMIT=$(grep -i '^anthropic-ratelimit-output-tokens-limit:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
RESET=$(grep -i '^anthropic-ratelimit-output-tokens-reset:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
|
||||||
|
# Fallback: sommige plans gebruiken `requests` ipv `tokens` voor de hoofdgrens.
|
||||||
|
if [[ -z "$REMAINING" ]]; then
|
||||||
|
REMAINING=$(grep -i '^anthropic-ratelimit-requests-remaining:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
LIMIT=$(grep -i '^anthropic-ratelimit-requests-limit:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
RESET=$(grep -i '^anthropic-ratelimit-requests-reset:' "$HDR_FILE" 2>/dev/null | awk '{print $2}' | tr -d '\r')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REMAINING" || -z "$LIMIT" ]]; then
|
||||||
|
printf '{"error":"no rate-limit headers in response","http_status":%s}\n' "$HTTP_STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pct als integer (rounded). Bij limit=0 zou je delen door nul — bescherm.
|
||||||
|
if [[ "$LIMIT" == "0" ]]; then
|
||||||
|
PCT=0
|
||||||
|
else
|
||||||
|
PCT=$(awk -v r="$REMAINING" -v l="$LIMIT" 'BEGIN { printf("%d", (r/l)*100) }')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reset-time is al ISO-8601 in de header bij Anthropic; geef ongewijzigd door.
|
||||||
|
RESET_ESCAPED="${RESET:-}"
|
||||||
|
|
||||||
|
printf '{"remaining":%s,"limit":%s,"pct":%s,"reset_at_iso":"%s","http_status":%s}\n' \
|
||||||
|
"$REMAINING" "$LIMIT" "$PCT" "$RESET_ESCAPED" "$HTTP_STATUS"
|
||||||
|
|
@ -25,20 +25,22 @@ services:
|
||||||
- /tmp:size=4g,mode=1777
|
- /tmp:size=4g,mode=1777
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "${AGENT_HEALTH_PORT_HOST:-8080}:8080"
|
- "${AGENT_HEALTH_PORT_HOST:-18080}:8080"
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# N5095 heeft 4 cores. Geef agent er 3, laat 1 voor QTS.
|
# Apple M2 host (8 cores, ≥16 GB unified). Geef de agent royaal de
|
||||||
# Memory: 4GB is ruim voor één Claude Code sessie + één npm install.
|
# 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:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "3.0"
|
cpus: "6.0"
|
||||||
memory: 4g
|
memory: 8g
|
||||||
reservations:
|
reservations:
|
||||||
cpus: "0.5"
|
cpus: "1.0"
|
||||||
memory: 512m
|
memory: 1g
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-agent-runner",
|
"name": "scrum4me-agent-runner",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS",
|
"description": "Headless Claude Code worker dat de Scrum4Me job-queue leegt vanaf een NAS",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"down": "docker compose down",
|
"down": "docker compose down",
|
||||||
"logs": "docker compose logs -f",
|
"logs": "docker compose logs -f",
|
||||||
"rebuild": "docker compose build --no-cache && docker compose up -d",
|
"rebuild": "docker compose build --no-cache && docker compose up -d",
|
||||||
"health": "curl -fsS http://localhost:${AGENT_HEALTH_PORT_HOST:-8080}/health | jq ."
|
"health": "curl -fsS http://localhost:${AGENT_HEALTH_PORT_HOST:-18080}/health | jq ."
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue