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>
This commit is contained in:
parent
b6bea1ecbb
commit
a6079892d7
3 changed files with 475 additions and 197 deletions
236
CLAUDE.md
236
CLAUDE.md
|
|
@ -1,189 +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 handmatige push- of PR-acties nodig.** De
|
- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT)
|
||||||
`scrum4me-mcp`-server (zelfde container) doet de push automatisch
|
of de `primary_worktree_path` (idea-jobs). Buiten die directory en
|
||||||
zodra jij `update_job_status('done')` aanroept, en maakt — als het
|
`/var/log/agent` heb je niets te zoeken.
|
||||||
product `auto_pr=true` heeft — direct een PR aan met auto-merge
|
- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')`
|
||||||
(squash) actief. Roep dus geen `git push` of `gh pr create` zelf aan;
|
aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`).
|
||||||
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.
|
|
||||||
|
|
||||||
Volledige documentatie van de auto-PR-keten: `docs/runbooks/auto-pr-flow.md`
|
## Hardstop-regels (gelden ongeacht je kind)
|
||||||
in de Scrum4Me-repo.
|
|
||||||
|
|
||||||
## 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"*
|
## Project-CLAUDE.md (in worktree)
|
||||||
of equivalent:
|
|
||||||
|
|
||||||
0. **Pre-flight quota-check** (M13). Vóór elke `wait_for_job`-aanroep:
|
De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude
|
||||||
1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }`
|
automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de
|
||||||
2. `bash /opt/agent/bin/worker-quota-probe.sh` → JSON
|
Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die
|
||||||
`{ pct, reset_at_iso, ... }`
|
bevat de ST-code-commit-stijl, lint/test/build-commands, en project-
|
||||||
3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct,
|
specifieke patronen.
|
||||||
last_quota_check_at })` — server stuurt SSE-event zodat NavBar
|
|
||||||
stand-by-badge live updatet
|
|
||||||
4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot
|
|
||||||
`reset_at_iso`", sleep tot dat tijdstip (cap op 1 uur), spring
|
|
||||||
terug naar stap 0.2
|
|
||||||
5. **Anders**: ga door naar stap 1
|
|
||||||
1. Roep `mcp__scrum4me__wait_for_job` aan. Geen argumenten, geen wait-time
|
|
||||||
tweaken — de tool blokt zelf tot 600 s.
|
|
||||||
2. Als er een job geclaimd wordt:
|
|
||||||
1. Roep `bash /opt/agent/bin/job-prepare.sh <job_id> <repo_url>` aan
|
|
||||||
via Bash. Output is het pad van de working tree.
|
|
||||||
2. `cd` naar dat pad.
|
|
||||||
3. Lees de project-CLAUDE.md (`./CLAUDE.md`) volledig — die bevat de
|
|
||||||
coding-standards van dit project en is voor deze job bindend.
|
|
||||||
4. Voer het `implementation_plan` uit dat je van `wait_for_job` kreeg.
|
|
||||||
Volg de Commit Strategy uit de project-CLAUDE.md (commit per laag,
|
|
||||||
ST-code in de titel).
|
|
||||||
5. Voer de project-verificaties uit die de project-CLAUDE.md voorschrijft
|
|
||||||
(typisch `npm run lint && npm test && npm run build`).
|
|
||||||
6. **Verify-gate** (PBI-50 F0-2). Roep
|
|
||||||
`mcp__scrum4me__verify_task_against_plan({ task_id, worktree_path })`
|
|
||||||
aan. De tool draait `git diff <base_sha>...HEAD` en classificeert tegen
|
|
||||||
het frozen `implementation_plan`. Antwoord bevat `verify_result` +
|
|
||||||
`allowed_for_done`. Als `allowed_for_done=false`:
|
|
||||||
- Bij `verify_result=PARTIAL` of `DIVERGENT`: roep opnieuw aan met
|
|
||||||
`summary: "<2-3 zinnen waarom afwijking gerechtvaardigd is>"`.
|
|
||||||
- Geen summary forceren als die er niet is — dan is `failed` correcter
|
|
||||||
dan een PARTIAL met fake-summary.
|
|
||||||
7. **Per-task status** (PBI-50 F0-2). Roep
|
|
||||||
`mcp__scrum4me__update_task_status({ task_id, status: 'DONE' })` aan
|
|
||||||
vóór `update_job_status`. Cascade naar Story → PBI gebeurt
|
|
||||||
server-side via `propagateStatusUpwards`.
|
|
||||||
8. **Niet zelf pushen of PR's maken.** Lokaal committen op een
|
|
||||||
feature-branch is goed. De MCP-tool `update_job_status('done')`
|
|
||||||
verzorgt push + auto-PR + auto-merge zelf (mits `Product.auto_pr=true`).
|
|
||||||
9. Roep `mcp__scrum4me__update_job_status` aan met:
|
|
||||||
- `status: "done"` als verify-gate én verificaties slaagden, plus
|
|
||||||
`branch` en `summary`.
|
|
||||||
- `status: "failed"` met `error` als iets onomkeerbaar misging.
|
|
||||||
- Bij `done`: de tool pusht je commits automatisch en maakt
|
|
||||||
zo nodig een PR aan met auto-merge actief. Verwacht dus dat
|
|
||||||
de respons `pushed_at` en `pr_url` kan bevatten.
|
|
||||||
10. Roep `mcp__scrum4me__check_queue_empty` aan (geen args). Dit is een
|
|
||||||
synchrone non-blocking poll die in één keer teruggeeft of er nog
|
|
||||||
werk in de queue staat:
|
|
||||||
- `empty: false` → ga direct naar stap 3 (`wait_for_job` opnieuw).
|
|
||||||
- `empty: true` → batch is klaar; geef recap en exit. Geen extra
|
|
||||||
`wait_for_job`-call die 600 s blokt.
|
|
||||||
11. Roep `bash /opt/agent/bin/job-cleanup.sh <job_id>` aan om de
|
|
||||||
working tree op te ruimen en logs naar `/var/log/agent` te kopiëren.
|
|
||||||
3. Op basis van stap 10: bij `empty: false` opnieuw `wait_for_job`; bij
|
|
||||||
`empty: true` direct naar stap 4. Stop niet midden in de loop, vraag
|
|
||||||
niets.
|
|
||||||
4. Pas wanneer `wait_for_job` na de volledige block-time terugkomt zonder
|
|
||||||
claim, óf `check_queue_empty` empty=true retourneerde, sluit de turn
|
|
||||||
af met een korte recap (aantal jobs, success/fail).
|
|
||||||
|
|
||||||
## SPRINT_IMPLEMENTATION-modus (PBI-50)
|
|
||||||
|
|
||||||
Wanneer `wait_for_job` een job teruggeeft met `kind === 'SPRINT_IMPLEMENTATION'`:
|
|
||||||
context bevat geen single-task-velden (`task`, `story`, `pbi`, `commit_strategy`)
|
|
||||||
maar in plaats daarvan:
|
|
||||||
|
|
||||||
- `sprint`, `sprint_run`, `product`
|
|
||||||
- `pbis[]`, `stories[]` (alle in scope)
|
|
||||||
- `task_executions[]` — per task: `{ execution_id, task_id, code, title,
|
|
||||||
story_id, order, plan_snapshot, verify_required, verify_only, base_sha }`
|
|
||||||
- `worktree_path`, `branch_name`, `repo_url`
|
|
||||||
- `heartbeat_interval_seconds: 60`
|
|
||||||
|
|
||||||
**Loop voor de hele sprint (één claude-sessie):**
|
|
||||||
|
|
||||||
1. Lees project-CLAUDE.md (voor coding-standards) — dezelfde stap als PER_TASK.
|
|
||||||
2. Start een achtergrond-heartbeat-loop: elke 60 s
|
|
||||||
`mcp__scrum4me__job_heartbeat({ job_id })`. De respons bevat
|
|
||||||
`sprint_run_status` + `sprint_run_pause_reason`. Bij `sprint_run_status !==
|
|
||||||
'RUNNING'`: breek de task-loop direct (UI-cancel of sibling-fail).
|
|
||||||
3. Voor elke `execution` in `task_executions[]` (al gesorteerd op order):
|
|
||||||
1. **Quota-probe** (PBI-50 F4-T3). `worker_quota-probe.sh` →
|
|
||||||
`worker_heartbeat({ last_quota_pct })`. Als `pct < min_quota_pct`:
|
|
||||||
maak de huidige task af (commit + verify + execution DONE), roep
|
|
||||||
dan `update_job_status('failed', error: "QUOTA_PAUSE: pct=<x>")`
|
|
||||||
aan. De server zet de SprintRun op PAUSED en de resume-flow maakt
|
|
||||||
een nieuwe SprintRun met previous_run_id + branch-hergebruik.
|
|
||||||
2. `update_task_execution({ execution_id, status: 'RUNNING' })`.
|
|
||||||
3. Voer `plan_snapshot` uit. Commit per laag in dezelfde branch
|
|
||||||
(`branch_name` is gelijk aan `sprint_run.branch`). ST-codes per task.
|
|
||||||
4. Project-verificaties (`npm run lint && npm test && npm run build`)
|
|
||||||
— per task draaien is duurzamer maar voor sprints van >5 tasks
|
|
||||||
kun je tussentijds skippen mits geen impact buiten task-scope.
|
|
||||||
5. `verify_sprint_task({ execution_id, worktree_path, summary? })`.
|
|
||||||
Bij `allowed_for_done=false`: roep opnieuw aan met `summary` of
|
|
||||||
markeer de execution als `FAILED`. Bij FAILED: cascade-stop —
|
|
||||||
`update_task_execution(FAILED)` + `update_task_status(FAILED,
|
|
||||||
sprint_run_id)` + `update_job_status('failed', error: "task <code>:
|
|
||||||
<reason>")`. De rest van de task_executions wordt niet uitgevoerd.
|
|
||||||
6. `update_task_execution({ execution_id, status: 'DONE', head_sha:
|
|
||||||
<huidige HEAD> })`.
|
|
||||||
7. `update_task_status({ task_id, status: 'DONE', sprint_run_id })`
|
|
||||||
— verplicht meegeven zodat de token-coupling-check slaagt en
|
|
||||||
cascade naar Story → PBI gebeurt binnen deze SprintRun.
|
|
||||||
4. Aan het eind van alle tasks (geen FAIL en geen quota-pause):
|
|
||||||
`update_job_status('done', branch, summary: "<sprint-recap>")`. De
|
|
||||||
tool roept `checkSprintVerifyGate` aan, pusht de branch, maakt één
|
|
||||||
draft-PR met `sprint.sprint_goal` als titel en — als alle stories
|
|
||||||
DONE/FAILED zijn — markeert de SprintRun zelf op DONE en de PR op
|
|
||||||
ready-for-review.
|
|
||||||
5. Stop de heartbeat-loop, ga naar `check_queue_empty` zoals PER_TASK.
|
|
||||||
|
|
||||||
**Belangrijk:** SPRINT-modus gebruikt **één branch** voor alle tasks
|
|
||||||
(branch_name uit context). Geen branch-wissels per task. De
|
|
||||||
`base_sha` voor task[0] zit in execution.base_sha; task[1..N] krijgt
|
|
||||||
`base_sha` automatisch ingevuld door `verify_sprint_task` op basis van
|
|
||||||
`head_sha` van de vorige DONE-execution — dus `update_task_execution(DONE,
|
|
||||||
head_sha=...)` is **kritiek** voor de chain.
|
|
||||||
|
|
||||||
## Foutscenario's
|
## 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 handmatige `git push`. De MCP-tool `update_job_status('done')`
|
- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo.
|
||||||
pusht zelf via `pushBranchForJob`. Een eigen push verstoort de
|
- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo.
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -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,mcp__scrum4me__get_worker_settings,mcp__scrum4me__worker_heartbeat'
|
|
||||||
|
|
||||||
CONSEC_FAILURES=0
|
CONSEC_FAILURES=0
|
||||||
BACKOFF=${AGENT_BACKOFF_START}
|
BACKOFF=${AGENT_BACKOFF_START}
|
||||||
|
|
@ -60,32 +62,25 @@ 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}" \
|
tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1
|
||||||
--mcp-config /opt/agent/mcp-config.json \
|
|
||||||
--allowedTools "${ALLOWED_TOOLS}" \
|
|
||||||
--permission-mode bypassPermissions \
|
|
||||||
--output-format text \
|
|
||||||
> "${run_log}" 2>&1
|
|
||||||
exit_code=$?
|
exit_code=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
log "batch ended exit=${exit_code}"
|
log "batch ended exit=${exit_code}"
|
||||||
|
|
||||||
# Token-expiry detectie: 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).
|
||||||
|
if [[ "$exit_code" -eq 3 ]] || grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then
|
||||||
|
log "AUTH FAILURE detected (exit=$exit_code or pattern in log) — marking TOKEN_EXPIRED"
|
||||||
touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED"
|
touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED"
|
||||||
write_state "$(jq -n \
|
write_state "$(jq -n \
|
||||||
--arg endedAt "$iteration_end" \
|
--arg endedAt "$iteration_end" \
|
||||||
|
|
|
||||||
387
bin/run-one-job.ts
Normal file
387
bin/run-one-job.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
#!/usr/bin/env tsx
|
||||||
|
// run-one-job.ts — handelt één geclaimde Scrum4Me-ClaudeJob af.
|
||||||
|
//
|
||||||
|
// Architectuur (zie docs/plans/queue-loop-extraction.md in Scrum4Me-repo):
|
||||||
|
// scrum4me-docker/bin/run-agent.sh roept dit script per iteratie aan.
|
||||||
|
// Stappen:
|
||||||
|
// 1. getAuth → resolved userId/tokenId uit SCRUM4ME_TOKEN.
|
||||||
|
// 2. quota-probe (was Claude's verantwoordelijkheid in CLAUDE.md stappen 0.x).
|
||||||
|
// 3. resetStaleClaimedJobs → tryClaimJob, met LISTEN-fallback (270s) bij lege queue.
|
||||||
|
// 4. getFullJobContext → resolved JobConfig + kind-specifieke payload.
|
||||||
|
// 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION).
|
||||||
|
// 6. Schrijf payload naar /tmp/job-<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, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { Client as PgClient } from 'pg'
|
||||||
|
|
||||||
|
import { getAuth } from '/opt/scrum4me-mcp/src/auth.js'
|
||||||
|
import { prisma } from '/opt/scrum4me-mcp/src/prisma.js'
|
||||||
|
import {
|
||||||
|
attachWorktreeToJob,
|
||||||
|
getFullJobContext,
|
||||||
|
releaseLocksOnTerminal,
|
||||||
|
resetStaleClaimedJobs,
|
||||||
|
rollbackClaim,
|
||||||
|
tryClaimJob,
|
||||||
|
} from '/opt/scrum4me-mcp/src/tools/wait-for-job.js'
|
||||||
|
import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js'
|
||||||
|
import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js'
|
||||||
|
|
||||||
|
// ----- logging --------------------------------------------------------
|
||||||
|
const log = (msg: string) =>
|
||||||
|
console.log(`${new Date().toISOString()} [run-one-job] ${msg}`)
|
||||||
|
const logError = (msg: string) =>
|
||||||
|
console.error(`${new Date().toISOString()} [run-one-job] ERROR ${msg}`)
|
||||||
|
|
||||||
|
// ----- constants ------------------------------------------------------
|
||||||
|
const WAIT_DEADLINE_SECONDS = 270 // ruim binnen MAX_WAIT_SECONDS van wait_for_job
|
||||||
|
const POLL_INTERVAL_MS = 5000
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 60_000
|
||||||
|
const MCP_CONFIG = '/opt/agent/mcp-config.json'
|
||||||
|
const QUOTA_PROBE_PATH = '/opt/agent/bin/worker-quota-probe.sh'
|
||||||
|
const QUOTA_BACKOFF_CAP_MS = 30 * 60 * 1000
|
||||||
|
const TOKEN_EXPIRY_PATTERNS: RegExp[] = [
|
||||||
|
/invalid_api_key/i,
|
||||||
|
/authentication.*failed/i,
|
||||||
|
/401.*unauthor/i,
|
||||||
|
/OAuth.*expired/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ----- quota probe ----------------------------------------------------
|
||||||
|
async function quotaProbe(userId: string): Promise<void> {
|
||||||
|
const probe = spawnSync(QUOTA_PROBE_PATH, [], { encoding: 'utf8' })
|
||||||
|
if (probe.status !== 0) {
|
||||||
|
logError(
|
||||||
|
`quota probe failed: status=${probe.status} stderr=${(probe.stderr ?? '').trim()}`,
|
||||||
|
)
|
||||||
|
throw new Error('quota probe failed')
|
||||||
|
}
|
||||||
|
let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string }
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(probe.stdout)
|
||||||
|
} catch {
|
||||||
|
logError(`quota probe stdout not JSON: ${probe.stdout.slice(0, 200)}`)
|
||||||
|
throw new Error('quota probe stdout invalid')
|
||||||
|
}
|
||||||
|
if (parsed.pct === undefined) {
|
||||||
|
logError(`quota probe missing pct field: ${probe.stdout.slice(0, 200)}`)
|
||||||
|
throw new Error('quota probe missing pct')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { min_quota_pct: true },
|
||||||
|
})
|
||||||
|
const minPct = user?.min_quota_pct ?? 0
|
||||||
|
|
||||||
|
log(
|
||||||
|
`quota probe pct=${parsed.pct} min_quota_pct=${minPct} ` +
|
||||||
|
`reset_at=${parsed.reset_at_iso ?? '-'}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (parsed.pct < minPct) {
|
||||||
|
let sleepMs = QUOTA_BACKOFF_CAP_MS
|
||||||
|
if (parsed.reset_at_iso) {
|
||||||
|
const resetAt = new Date(parsed.reset_at_iso).getTime()
|
||||||
|
const delta = resetAt - Date.now()
|
||||||
|
if (delta > 0 && delta < sleepMs) sleepMs = delta
|
||||||
|
}
|
||||||
|
log(`quota below min — sleeping ${Math.round(sleepMs / 1000)}s until reset`)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, sleepMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- LISTEN-fallback voor lege queue -------------------------------
|
||||||
|
async function waitForEnqueue(userId: string): Promise<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}`)
|
||||||
|
|
||||||
|
// 1. Quota probe (gate vóór elke claim).
|
||||||
|
try {
|
||||||
|
await quotaProbe(userId)
|
||||||
|
} catch (err) {
|
||||||
|
logError(`quota probe error: ${(err as Error).message}`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reset stale claims, then attempt to claim.
|
||||||
|
await resetStaleClaimedJobs(userId)
|
||||||
|
let jobId = await tryClaimJob(userId, tokenId)
|
||||||
|
if (!jobId) {
|
||||||
|
log(`no job claimed — LISTEN scrum4me_changes deadline=${WAIT_DEADLINE_SECONDS}s`)
|
||||||
|
await waitForEnqueue(userId)
|
||||||
|
await resetStaleClaimedJobs(userId)
|
||||||
|
jobId = await tryClaimJob(userId, tokenId)
|
||||||
|
}
|
||||||
|
if (!jobId) {
|
||||||
|
log(`claim timeout after ${WAIT_DEADLINE_SECONDS}s — exiting 0`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`claimed job_id=${jobId}`)
|
||||||
|
|
||||||
|
// 3. Resolve full context.
|
||||||
|
let ctx: Awaited<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)
|
||||||
|
const args: string[] = [
|
||||||
|
'-p',
|
||||||
|
promptText,
|
||||||
|
'--model',
|
||||||
|
cfg.model,
|
||||||
|
'--permission-mode',
|
||||||
|
cfg.permission_mode,
|
||||||
|
'--allowedTools',
|
||||||
|
(cfg.allowed_tools ?? []).join(','),
|
||||||
|
'--mcp-config',
|
||||||
|
MCP_CONFIG,
|
||||||
|
'--add-dir',
|
||||||
|
'/opt/agent',
|
||||||
|
'--output-format',
|
||||||
|
'text',
|
||||||
|
]
|
||||||
|
if (effort) args.push('--effort', effort)
|
||||||
|
|
||||||
|
const cwd = worktreePath ?? '/opt/agent'
|
||||||
|
// Log args zonder de volledige prompt-tekst (kan kilo's groot zijn).
|
||||||
|
const argsForLog = args
|
||||||
|
.map((a, i) => (i === 1 ? `<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.
|
||||||
|
let tokenExpired = false
|
||||||
|
for (const pat of TOKEN_EXPIRY_PATTERNS) {
|
||||||
|
if (pat.test(stdoutBuf)) {
|
||||||
|
tokenExpired = true
|
||||||
|
log(`TOKEN_EXPIRED detected pattern="${pat.source}" exiting code=3`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Rollback claim if Claude exited non-zero without updating job-status.
|
||||||
|
// PBI-50 lease-driven recovery vangt resterende stale jobs op na 5 min.
|
||||||
|
if (exitCode !== 0 && !tokenExpired) {
|
||||||
|
const jobNow = await prisma.claudeJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
if (jobNow?.status === 'CLAIMED' || jobNow?.status === 'RUNNING') {
|
||||||
|
log(
|
||||||
|
`rollback claim job_id=${jobId} reason=claude_exit_${exitCode}_without_status_update`,
|
||||||
|
)
|
||||||
|
await rollbackClaim(jobId).catch(() => {})
|
||||||
|
await releaseLocksOnTerminal(jobId).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Cleanup payload directory.
|
||||||
|
try {
|
||||||
|
rmSync(payloadDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
log(`cleanup payload_removed=true heartbeat_stopped=${heartbeatTimer !== null}`)
|
||||||
|
|
||||||
|
if (tokenExpired) return 3
|
||||||
|
return exitCode ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- entry ----------------------------------------------------------
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
prisma.$disconnect().finally(() => process.exit(143))
|
||||||
|
})
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async (code) => {
|
||||||
|
log(`exit code=${code}`)
|
||||||
|
await prisma.$disconnect().catch(() => {})
|
||||||
|
process.exit(code)
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
logError(`fatal: ${(err as Error).stack ?? err}`)
|
||||||
|
await prisma.$disconnect().catch(() => {})
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue