diff --git a/CLAUDE.md b/CLAUDE.md index 82e6fb5..dea1f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,189 +1,85 @@ # CLAUDE.md — Scrum4Me NAS-runner -Je draait als headless worker op een QNAP NAS. Dit document beschrijft -je rol; het wordt automatisch geladen door `claude -p` vanuit -`/opt/agent/`. +Je draait als headless worker op een QNAP NAS (of lokale Docker). Dit document +wordt automatisch geladen door `claude -p` vanuit `/opt/agent/` en geeft je de +**identiteit** en de **hardstop-regels** voor deze container. De per-job +**workflow** krijg je in de prompt zelf van `bin/run-one-job.ts`. + +## Architectuur (sinds queue-loop-refactor) + +`bin/run-agent.sh` is de daemon-loop (backoff/health/log-rotation). Elke +iteratie roept hij `tsx /opt/agent/bin/run-one-job.ts` aan. Die runner doet: + +1. `getAuth` → `tryClaimJob` (één job, atomically). +2. `getFullJobContext` → resolved `JobConfig` (PBI-67) + payload. +3. Bouw Claude CLI-args: `--model`, `--permission-mode`, `--effort`, + `--allowedTools`, `--mcp-config`, `--output-format text`. +4. `spawn 'claude' …` met cwd = worktree_path en een **kind-specifieke + prompt** (uit `scrum4me-mcp/src/prompts//`). +5. Wacht op exit; cleanup; loop terug naar run-agent.sh. + +**Eén Claude-invocation = één geclaimde job.** Jij voert alleen die ene +job uit en sluit dan af. ## Identiteit - Je bent ingelogd via een **dedicated agent-user** in Scrum4Me, niet als de eindgebruiker. Commits, story-logs en `claude_jobs.claimed_by_token_id` - zullen jouw token tonen. -- Je hebt **geen handmatige push- of PR-acties nodig.** De - `scrum4me-mcp`-server (zelfde container) doet de push automatisch - zodra jij `update_job_status('done')` aanroept, en maakt — als het - product `auto_pr=true` heeft — direct een PR aan met auto-merge - (squash) actief. Roep dus geen `git push` of `gh pr create` zelf aan; - laat de MCP-laag dat doen. -- Je opereert binnen `/tmp/job-` per job. Buiten die directory en - buiten `/var/log/agent` heb je niets te zoeken. + tonen jouw token. +- Je opereert binnen het `worktree_path` dat de runner je geeft (TASK/SPRINT) + of de `primary_worktree_path` (idea-jobs). Buiten die directory en + `/var/log/agent` heb je niets te zoeken. +- Je hebt **geen handmatige push- of PR-acties nodig.** Roep `update_job_status('done')` + aan; de MCP-tool doet automatisch push + auto-PR (mits `Product.auto_pr=true`). -Volledige documentatie van de auto-PR-keten: `docs/runbooks/auto-pr-flow.md` -in de Scrum4Me-repo. +## Hardstop-regels (gelden ongeacht je kind) -## Operationele loop (verplicht) +- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor + je geclaimd. Eén invocation = één job. +- **GEEN** `mcp__scrum4me__check_queue_empty`. Sluit af na deze ene job. +- **GEEN** `mcp__scrum4me__job_heartbeat` voor SPRINT_IMPLEMENTATION. De + runner verlengt de lease automatisch via setInterval (60s) — onafhankelijk + van jouw tool-call-cadans. +- **Geen handmatige `git push` of `gh pr create`.** De MCP-tool + `update_job_status('done')` doet push + auto-PR via `pushBranchForJob` + en `maybeCreateAutoPr`. +- **Geen `npm publish`, `vercel deploy`, of andere release-actions** buiten + de PR-flow om. +- **Geen long-running processes** (servers, watchers). Builds en tests + moeten zelfstandig terminaten. +- **Geen edits buiten `worktree_path` of `/tmp/job-*`.** +- **Geen credentials uitprinten** of in commits stoppen. -Wanneer je geseed wordt met *"Pak de volgende job uit de Scrum4Me-queue"* -of equivalent: +## Project-CLAUDE.md (in worktree) -0. **Pre-flight quota-check** (M13). Vóór elke `wait_for_job`-aanroep: - 1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }` - 2. `bash /opt/agent/bin/worker-quota-probe.sh` → JSON - `{ pct, reset_at_iso, ... }` - 3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, - last_quota_check_at })` — server stuurt SSE-event zodat NavBar - stand-by-badge live updatet - 4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot - `reset_at_iso`", sleep tot dat tijdstip (cap op 1 uur), spring - terug naar stap 0.2 - 5. **Anders**: ga door naar stap 1 -1. Roep `mcp__scrum4me__wait_for_job` aan. Geen argumenten, geen wait-time - tweaken — de tool blokt zelf tot 600 s. -2. Als er een job geclaimd wordt: - 1. Roep `bash /opt/agent/bin/job-prepare.sh ` aan - via Bash. Output is het pad van de working tree. - 2. `cd` naar dat pad. - 3. Lees de project-CLAUDE.md (`./CLAUDE.md`) volledig — die bevat de - coding-standards van dit project en is voor deze job bindend. - 4. Voer het `implementation_plan` uit dat je van `wait_for_job` kreeg. - Volg de Commit Strategy uit de project-CLAUDE.md (commit per laag, - ST-code in de titel). - 5. Voer de project-verificaties uit die de project-CLAUDE.md voorschrijft - (typisch `npm run lint && npm test && npm run build`). - 6. **Verify-gate** (PBI-50 F0-2). Roep - `mcp__scrum4me__verify_task_against_plan({ task_id, worktree_path })` - aan. De tool draait `git diff ...HEAD` en classificeert tegen - het frozen `implementation_plan`. Antwoord bevat `verify_result` + - `allowed_for_done`. Als `allowed_for_done=false`: - - Bij `verify_result=PARTIAL` of `DIVERGENT`: roep opnieuw aan met - `summary: "<2-3 zinnen waarom afwijking gerechtvaardigd is>"`. - - Geen summary forceren als die er niet is — dan is `failed` correcter - dan een PARTIAL met fake-summary. - 7. **Per-task status** (PBI-50 F0-2). Roep - `mcp__scrum4me__update_task_status({ task_id, status: 'DONE' })` aan - vóór `update_job_status`. Cascade naar Story → PBI gebeurt - server-side via `propagateStatusUpwards`. - 8. **Niet zelf pushen of PR's maken.** Lokaal committen op een - feature-branch is goed. De MCP-tool `update_job_status('done')` - verzorgt push + auto-PR + auto-merge zelf (mits `Product.auto_pr=true`). - 9. Roep `mcp__scrum4me__update_job_status` aan met: - - `status: "done"` als verify-gate én verificaties slaagden, plus - `branch` en `summary`. - - `status: "failed"` met `error` als iets onomkeerbaar misging. - - Bij `done`: de tool pusht je commits automatisch en maakt - zo nodig een PR aan met auto-merge actief. Verwacht dus dat - de respons `pushed_at` en `pr_url` kan bevatten. - 10. Roep `mcp__scrum4me__check_queue_empty` aan (geen args). Dit is een - synchrone non-blocking poll die in één keer teruggeeft of er nog - werk in de queue staat: - - `empty: false` → ga direct naar stap 3 (`wait_for_job` opnieuw). - - `empty: true` → batch is klaar; geef recap en exit. Geen extra - `wait_for_job`-call die 600 s blokt. - 11. Roep `bash /opt/agent/bin/job-cleanup.sh ` aan om de - working tree op te ruimen en logs naar `/var/log/agent` te kopiëren. -3. Op basis van stap 10: bij `empty: false` opnieuw `wait_for_job`; bij - `empty: true` direct naar stap 4. Stop niet midden in de loop, vraag - niets. -4. Pas wanneer `wait_for_job` na de volledige block-time terugkomt zonder - claim, óf `check_queue_empty` empty=true retourneerde, sluit de turn - af met een korte recap (aantal jobs, success/fail). - -## SPRINT_IMPLEMENTATION-modus (PBI-50) - -Wanneer `wait_for_job` een job teruggeeft met `kind === 'SPRINT_IMPLEMENTATION'`: -context bevat geen single-task-velden (`task`, `story`, `pbi`, `commit_strategy`) -maar in plaats daarvan: - -- `sprint`, `sprint_run`, `product` -- `pbis[]`, `stories[]` (alle in scope) -- `task_executions[]` — per task: `{ execution_id, task_id, code, title, - story_id, order, plan_snapshot, verify_required, verify_only, base_sha }` -- `worktree_path`, `branch_name`, `repo_url` -- `heartbeat_interval_seconds: 60` - -**Loop voor de hele sprint (één claude-sessie):** - -1. Lees project-CLAUDE.md (voor coding-standards) — dezelfde stap als PER_TASK. -2. Start een achtergrond-heartbeat-loop: elke 60 s - `mcp__scrum4me__job_heartbeat({ job_id })`. De respons bevat - `sprint_run_status` + `sprint_run_pause_reason`. Bij `sprint_run_status !== - 'RUNNING'`: breek de task-loop direct (UI-cancel of sibling-fail). -3. Voor elke `execution` in `task_executions[]` (al gesorteerd op order): - 1. **Quota-probe** (PBI-50 F4-T3). `worker_quota-probe.sh` → - `worker_heartbeat({ last_quota_pct })`. Als `pct < min_quota_pct`: - maak de huidige task af (commit + verify + execution DONE), roep - dan `update_job_status('failed', error: "QUOTA_PAUSE: pct=")` - aan. De server zet de SprintRun op PAUSED en de resume-flow maakt - een nieuwe SprintRun met previous_run_id + branch-hergebruik. - 2. `update_task_execution({ execution_id, status: 'RUNNING' })`. - 3. Voer `plan_snapshot` uit. Commit per laag in dezelfde branch - (`branch_name` is gelijk aan `sprint_run.branch`). ST-codes per task. - 4. Project-verificaties (`npm run lint && npm test && npm run build`) - — per task draaien is duurzamer maar voor sprints van >5 tasks - kun je tussentijds skippen mits geen impact buiten task-scope. - 5. `verify_sprint_task({ execution_id, worktree_path, summary? })`. - Bij `allowed_for_done=false`: roep opnieuw aan met `summary` of - markeer de execution als `FAILED`. Bij FAILED: cascade-stop — - `update_task_execution(FAILED)` + `update_task_status(FAILED, - sprint_run_id)` + `update_job_status('failed', error: "task : - ")`. De rest van de task_executions wordt niet uitgevoerd. - 6. `update_task_execution({ execution_id, status: 'DONE', head_sha: - })`. - 7. `update_task_status({ task_id, status: 'DONE', sprint_run_id })` - — verplicht meegeven zodat de token-coupling-check slaagt en - cascade naar Story → PBI gebeurt binnen deze SprintRun. -4. Aan het eind van alle tasks (geen FAIL en geen quota-pause): - `update_job_status('done', branch, summary: "")`. De - tool roept `checkSprintVerifyGate` aan, pusht de branch, maakt één - draft-PR met `sprint.sprint_goal` als titel en — als alle stories - DONE/FAILED zijn — markeert de SprintRun zelf op DONE en de PR op - ready-for-review. -5. Stop de heartbeat-loop, ga naar `check_queue_empty` zoals PER_TASK. - -**Belangrijk:** SPRINT-modus gebruikt **één branch** voor alle tasks -(branch_name uit context). Geen branch-wissels per task. De -`base_sha` voor task[0] zit in execution.base_sha; task[1..N] krijgt -`base_sha` automatisch ingevuld door `verify_sprint_task` op basis van -`head_sha` van de vorige DONE-execution — dus `update_task_execution(DONE, -head_sha=...)` is **kritiek** voor de chain. +De runner zet je `cwd` op het `worktree_path`. Daardoor laadt Claude +automatisch ook de **project-CLAUDE.md** uit de worktree (bv. de +Scrum4Me-codebase-conventies). Lees die voor je begint te coderen — die +bevat de ST-code-commit-stijl, lint/test/build-commands, en project- +specifieke patronen. ## Foutscenario's -- **`job-prepare.sh` faalt** (clone-fout, disk-fout): rapporteer - `update_job_status('failed', error=...)` en ga door met de volgende job. - Niet retry'en — als de cache stuk is, zal de volgende job ook falen en - zal de wrapper merken dat we te veel fouten op rij hebben. -- **Verificatie faalt** (lint/test/build rood): rapporteer `failed` met - de tail van de output in `error`. Geen automatische fix-attempts; de - eindgebruiker beslist of ze het plan aanpassen. -- **Onverwachte runtime-fout** in de tools: laat de exception propageren. - De wrapper-loop schrijft een run-log en herstart `claude -p` met backoff. +- **Verificatie faalt** (lint/test/build rood): roep + `update_job_status('failed', error: )` aan en sluit af. Geen + automatische fix-attempts; de eindgebruiker beslist. +- **Verify-gate DIVERGENT**: roep `verify_task_against_plan` opnieuw aan + met een `summary` die de afwijking onderbouwt, óf rapporteer `failed`. +- **Onverwachte runtime-fout**: laat de exception propageren. De runner + detecteert exit≠0 zonder `update_job_status` en doet rollbackClaim; + de wrapper-loop in run-agent.sh schrijft een run-log en herstart met + backoff. ## Vraag-antwoord-kanaal (M11) -Als het `implementation_plan` ambigu is op een keuze die niet uit de -acceptance-criteria volgt: gebruik `mcp__scrum4me__ask_user_question` -met een korte vraag plus 2–4 `options`. Geef `wait_seconds: 600` mee -zodat de tool blijft wachten. Als de timer afloopt zonder antwoord: -status `failed`, `error: "Wacht op gebruikersantwoord op vraag "`, -en ga door met de volgende job. +Voor blokkerende keuzes die niet uit het plan volgen: gebruik +`mcp__scrum4me__ask_user_question` met 2–4 `options` en `wait_seconds: 600`. +Bij timeout: `update_job_status('failed', error: "Wacht op gebruikersantwoord +op vraag ")`. Niet gokken. Niet aannemen. -Niet gokken. Niet aannemen. +## Verwijzingen -## Wat je NIET doet - -- Geen handmatige `git push`. De MCP-tool `update_job_status('done')` - pusht zelf via `pushBranchForJob`. Een eigen push verstoort de - pushed_at-tracking en kan branch-conflicts veroorzaken met - sibling-jobs in dezelfde story. -- Geen `gh pr create` of `gh pr merge`. De MCP-tool `maybeCreateAutoPr` - doet dit afhankelijk van `Product.auto_pr`. -- Geen `npm publish`, `vercel deploy`, of welke release-actie dan ook - buiten de PR-flow om. -- Geen edits buiten `/tmp/job-*` (geen `~/.bashrc`, geen `/etc/...`, - geen andere shares). -- Geen credentials uitprinten of in commit-messages stoppen — `.env` - zit niet in deze container's WORKDIR maar dat ontslaat je niet van - de gewoonte. -- Geen long-running shell-processes starten (servers, watchers). Builds - en tests moeten zelfstandig terminate'n. +- Per-kind workflows: zie de prompt die de runner je in `claude -p` meegeeft + (komt uit `scrum4me-mcp/src/prompts//`). +- Auto-PR-keten: `docs/runbooks/auto-pr-flow.md` in de Scrum4Me-repo. +- Refactor-plan: `docs/plans/queue-loop-extraction.md` in de Scrum4Me-repo. diff --git a/bin/run-agent.sh b/bin/run-agent.sh index 9097261..c67213a 100644 --- a/bin/run-agent.sh +++ b/bin/run-agent.sh @@ -3,14 +3,19 @@ # # Strategie: # - Eerst pre-flight token-check (eenmalig, blokkeert start bij faal) -# - Loop: claude -p met seed-prompt -# - Exit 0 → de queue was leeg, sleep kort, herhaal +# - Loop: tsx /opt/agent/bin/run-one-job.ts (één geclaimde job per iteratie) +# - Exit 0 → de queue was leeg of de job is afgerond, sleep kort, herhaal +# - Exit 3 → run-one-job detecteerde TOKEN_EXPIRED in Claude-output # - Exit ≠ 0 → exponential backoff, log, schrijf state, herhaal # - Bij N opeenvolgende fouten → schrijf UNHEALTHY marker; health # endpoint gaat op 503, container blijft runnen voor diagnose # - Bij gedetecteerde token-expiry → schrijf TOKEN_EXPIRED marker # en exit (compose start opnieuw, maar entrypoint zal dezelfde # marker zien via health-server) +# +# Claim/exec-loop zit in bin/run-one-job.ts (Node + tsx); deze shell doet +# alleen daemon/backoff/health/log-rotation. Zie docs/plans/queue-loop-extraction.md +# in de Scrum4Me-repo. set -uo pipefail # let op: geen -e, we willen exit-codes inspecteren @@ -40,13 +45,10 @@ rm -f "${AGENT_STATE_DIR}/UNHEALTHY" "${AGENT_STATE_DIR}/TOKEN_EXPIRED" /opt/agent/bin/rotate-logs.sh || true /opt/agent/bin/log-cleanup.sh || true -# ----- seed prompt ------------------------------------------------------ -SEED_PROMPT='Pak de volgende job uit de Scrum4Me-queue en draai de queue leeg volgens de loop in /opt/agent/CLAUDE.md. Niet stoppen tussen jobs door. Sluit pas af zodra wait_for_job na de volledige block-time terugkomt zonder claim.' - -# Tools-allowlist: alle MCP-tools die scrum4me-mcp aanbiedt + standaard -# file/bash-tools. Geen WebFetch, geen WebSearch — de agent heeft die -# niet nodig en uitsluiting verkleint het surface. -ALLOWED_TOOLS='Read,Edit,Write,Bash,Grep,Glob,mcp__scrum4me__health,mcp__scrum4me__list_products,mcp__scrum4me__get_claude_context,mcp__scrum4me__wait_for_job,mcp__scrum4me__check_queue_empty,mcp__scrum4me__update_job_status,mcp__scrum4me__update_task_status,mcp__scrum4me__update_task_plan,mcp__scrum4me__log_implementation,mcp__scrum4me__log_test_result,mcp__scrum4me__log_commit,mcp__scrum4me__create_pbi,mcp__scrum4me__create_story,mcp__scrum4me__create_task,mcp__scrum4me__create_todo,mcp__scrum4me__ask_user_question,mcp__scrum4me__get_question_answer,mcp__scrum4me__list_open_questions,mcp__scrum4me__cancel_question,mcp__scrum4me__get_worker_settings,mcp__scrum4me__worker_heartbeat' +# Geen seed-prompt en geen ALLOWED_TOOLS-string meer: per-job CLI-flags +# (incl. --model, --permission-mode, --effort, --allowedTools en de +# kind-specifieke prompt) worden door run-one-job.ts gebouwd uit +# JobConfig (resolved via PBI-67's resolveJobConfig). CONSEC_FAILURES=0 BACKOFF=${AGENT_BACKOFF_START} @@ -60,32 +62,25 @@ while true; do --argjson failures "$CONSEC_FAILURES" \ '{status:"running", currentBatchStartedAt:$started, consecutiveFailures:$failures}')" - log "starting batch (log: ${run_log})" + log "starting iteration (log: ${run_log})" - # claude -p met onze MCP-config en allowlist. - # cwd = /opt/agent zodat onze CLAUDE.md auto-geladen wordt. - # - # --permission-mode bypassPermissions: alle resterende permission- - # prompts uit. Veilig in deze container omdat (1) we draaien als - # non-root agent-user, (2) geen push-credentials, (3) writes - # gelimiteerd tot /tmp/job-*. De allowlist hierboven blijft als - # belt-and-braces second filter. + # Eén iteratie = één geclaimde job (of "geen job" → exit 0). De runner + # claimt zelf via tryClaimJob, leest JobConfig (PBI-67), bouwt de + # juiste Claude CLI-args, spawnt 'claude', wacht, sluit af. set +e - claude -p "${SEED_PROMPT}" \ - --mcp-config /opt/agent/mcp-config.json \ - --allowedTools "${ALLOWED_TOOLS}" \ - --permission-mode bypassPermissions \ - --output-format text \ - > "${run_log}" 2>&1 + tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 exit_code=$? set -e iteration_end=$(date -u +%Y-%m-%dT%H:%M:%SZ) log "batch ended exit=${exit_code}" - # Token-expiry detectie: parse stderr/stdout op bekende strings. - if grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then - log "AUTH FAILURE detected in run log — marking TOKEN_EXPIRED" + # Token-expiry detectie: run-one-job.ts retourneert exit 3 wanneer het + # bekende auth-error-strings in Claude's output ziet. We checken óók de + # log-tekst voor het geval een ander pad het patroon raakt (bv. Prisma- + # connection-error met OAuth-expired in error-body). + if [[ "$exit_code" -eq 3 ]] || grep -qE '(invalid_api_key|authentication.*failed|401.*unauthor|OAuth.*expired)' "${run_log}"; then + log "AUTH FAILURE detected (exit=$exit_code or pattern in log) — marking TOKEN_EXPIRED" touch "${AGENT_STATE_DIR}/TOKEN_EXPIRED" write_state "$(jq -n \ --arg endedAt "$iteration_end" \ diff --git a/bin/run-one-job.ts b/bin/run-one-job.ts new file mode 100644 index 0000000..2aaa50c --- /dev/null +++ b/bin/run-one-job.ts @@ -0,0 +1,387 @@ +#!/usr/bin/env tsx +// run-one-job.ts — handelt één geclaimde Scrum4Me-ClaudeJob af. +// +// Architectuur (zie docs/plans/queue-loop-extraction.md in Scrum4Me-repo): +// scrum4me-docker/bin/run-agent.sh roept dit script per iteratie aan. +// Stappen: +// 1. getAuth → resolved userId/tokenId uit SCRUM4ME_TOKEN. +// 2. quota-probe (was Claude's verantwoordelijkheid in CLAUDE.md stappen 0.x). +// 3. resetStaleClaimedJobs → tryClaimJob, met LISTEN-fallback (270s) bij lege queue. +// 4. getFullJobContext → resolved JobConfig + kind-specifieke payload. +// 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION). +// 6. Schrijf payload naar /tmp/job-/payload.json. +// 7. Bouw CLI-args uit ctx.config + mapBudgetToEffort. +// 8. setInterval(60s) lease-renewal voor SPRINT_IMPLEMENTATION. +// 9. spawn 'claude' met inherited stdio + scan voor token-expiry-patterns. +// 10. try/finally: bij Claude-exit≠0 zonder update_job_status → rollbackClaim. +// 11. cleanup payload + prisma.$disconnect(). +// +// Exit-codes: +// 0 = job afgehandeld of geen job binnen wait-deadline (idle) +// 1 = generieke fout (claim, context-fetch, worktree, spawn) +// 3 = TOKEN_EXPIRED detected → run-agent.sh schrijft TOKEN_EXPIRED marker + +import { spawn, spawnSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' + +import { Client as PgClient } from 'pg' + +import { getAuth } from '/opt/scrum4me-mcp/src/auth.js' +import { prisma } from '/opt/scrum4me-mcp/src/prisma.js' +import { + attachWorktreeToJob, + getFullJobContext, + releaseLocksOnTerminal, + resetStaleClaimedJobs, + rollbackClaim, + tryClaimJob, +} from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' +import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js' +import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js' + +// ----- logging -------------------------------------------------------- +const log = (msg: string) => + console.log(`${new Date().toISOString()} [run-one-job] ${msg}`) +const logError = (msg: string) => + console.error(`${new Date().toISOString()} [run-one-job] ERROR ${msg}`) + +// ----- constants ------------------------------------------------------ +const WAIT_DEADLINE_SECONDS = 270 // ruim binnen MAX_WAIT_SECONDS van wait_for_job +const POLL_INTERVAL_MS = 5000 +const HEARTBEAT_INTERVAL_MS = 60_000 +const MCP_CONFIG = '/opt/agent/mcp-config.json' +const QUOTA_PROBE_PATH = '/opt/agent/bin/worker-quota-probe.sh' +const QUOTA_BACKOFF_CAP_MS = 30 * 60 * 1000 +const TOKEN_EXPIRY_PATTERNS: RegExp[] = [ + /invalid_api_key/i, + /authentication.*failed/i, + /401.*unauthor/i, + /OAuth.*expired/i, +] + +// ----- quota probe ---------------------------------------------------- +async function quotaProbe(userId: string): Promise { + const probe = spawnSync(QUOTA_PROBE_PATH, [], { encoding: 'utf8' }) + if (probe.status !== 0) { + logError( + `quota probe failed: status=${probe.status} stderr=${(probe.stderr ?? '').trim()}`, + ) + throw new Error('quota probe failed') + } + let parsed: { pct?: number; limit?: number; remaining?: number; reset_at_iso?: string } + try { + parsed = JSON.parse(probe.stdout) + } catch { + logError(`quota probe stdout not JSON: ${probe.stdout.slice(0, 200)}`) + throw new Error('quota probe stdout invalid') + } + if (parsed.pct === undefined) { + logError(`quota probe missing pct field: ${probe.stdout.slice(0, 200)}`) + throw new Error('quota probe missing pct') + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { min_quota_pct: true }, + }) + const minPct = user?.min_quota_pct ?? 0 + + log( + `quota probe pct=${parsed.pct} min_quota_pct=${minPct} ` + + `reset_at=${parsed.reset_at_iso ?? '-'}`, + ) + + if (parsed.pct < minPct) { + let sleepMs = QUOTA_BACKOFF_CAP_MS + if (parsed.reset_at_iso) { + const resetAt = new Date(parsed.reset_at_iso).getTime() + const delta = resetAt - Date.now() + if (delta > 0 && delta < sleepMs) sleepMs = delta + } + log(`quota below min — sleeping ${Math.round(sleepMs / 1000)}s until reset`) + await new Promise((resolve) => setTimeout(resolve, sleepMs)) + } +} + +// ----- LISTEN-fallback voor lege queue ------------------------------- +async function waitForEnqueue(userId: string): Promise { + const dburl = process.env.DATABASE_URL + if (!dburl) throw new Error('DATABASE_URL not set') + const client = new PgClient({ connectionString: dburl }) + await client.connect() + await client.query('LISTEN scrum4me_changes') + + const deadline = Date.now() + WAIT_DEADLINE_SECONDS * 1000 + + try { + while (Date.now() < deadline) { + await new Promise((resolve) => { + const pollTimer = setTimeout(resolve, POLL_INTERVAL_MS) + const onNotify = (msg: { payload?: string }) => { + try { + const payload = JSON.parse(msg.payload ?? '{}') + if ( + payload.type === 'claude_job_enqueued' && + payload.user_id === userId + ) { + clearTimeout(pollTimer) + client.removeListener('notification', onNotify) + resolve() + } + } catch { + // ignore parse errors + } + } + client.on('notification', onNotify) + }) + // Out of the inner promise — caller will retry tryClaimJob. + return + } + } finally { + await client.end().catch(() => {}) + } +} + +// ----- main ----------------------------------------------------------- +async function main(): Promise { + log('claim attempt starting') + const { userId, tokenId } = await getAuth() + log(`auth ok user_id=${userId} token_id=${tokenId}`) + + // 1. Quota probe (gate vóór elke claim). + try { + await quotaProbe(userId) + } catch (err) { + logError(`quota probe error: ${(err as Error).message}`) + return 1 + } + + // 2. Reset stale claims, then attempt to claim. + await resetStaleClaimedJobs(userId) + let jobId = await tryClaimJob(userId, tokenId) + if (!jobId) { + log(`no job claimed — LISTEN scrum4me_changes deadline=${WAIT_DEADLINE_SECONDS}s`) + await waitForEnqueue(userId) + await resetStaleClaimedJobs(userId) + jobId = await tryClaimJob(userId, tokenId) + } + if (!jobId) { + log(`claim timeout after ${WAIT_DEADLINE_SECONDS}s — exiting 0`) + return 0 + } + + log(`claimed job_id=${jobId}`) + + // 3. Resolve full context. + let ctx: Awaited> = null + try { + ctx = await getFullJobContext(jobId) + } catch (err) { + logError(`getFullJobContext error job_id=${jobId} ${(err as Error).message}`) + log(`rollback claim job_id=${jobId} reason=context_fetch_failed`) + await rollbackClaim(jobId) + return 1 + } + if (!ctx) { + logError(`getFullJobContext returned null for job_id=${jobId}`) + await rollbackClaim(jobId) + return 1 + } + + // 4. Attach worktree for TASK_IMPLEMENTATION; sprint/idea-jobs hebben hun + // eigen worktree-pad al ingevuld door getFullJobContext. + // We werken hier met `any` omdat de return-type van getFullJobContext een + // discriminated union is en TypeScript hier zonder kind-narrow geen velden + // exposed; de runtime checks dekken alle paden af. + const ctxAny = ctx as any + let worktreePath: string | null = + ctxAny.worktree_path ?? ctxAny.primary_worktree_path ?? null + + if (ctx.kind === 'TASK_IMPLEMENTATION') { + if (!ctxAny.story || !ctxAny.task) { + logError(`TASK_IMPLEMENTATION job has incomplete story/task context`) + await rollbackClaim(jobId) + await releaseLocksOnTerminal(jobId) + return 1 + } + const wt = await attachWorktreeToJob( + ctxAny.product.id, + jobId, + ctxAny.story.id, + ctxAny.task.repo_url, + ) + if ('error' in wt) { + logError(`attachWorktreeToJob error job_id=${jobId} ${wt.error}`) + log(`rollback claim job_id=${jobId} reason=worktree_attach_failed`) + await rollbackClaim(jobId) + await releaseLocksOnTerminal(jobId) + return 1 + } + worktreePath = wt.worktree_path + ctxAny.worktree_path = wt.worktree_path + ctxAny.branch_name = wt.branch_name + log(`worktree path=${wt.worktree_path} branch=${wt.branch_name}`) + } else if (worktreePath) { + log(`worktree path=${worktreePath} (pre-resolved)`) + } + + // 5. Resolved config — log voor audit. + const cfg = ctx.config + const effort = mapBudgetToEffort(cfg.thinking_budget) + log( + `config job_id=${jobId} model=${cfg.model} ` + + `permission_mode=${cfg.permission_mode} ` + + `thinking_budget=${cfg.thinking_budget} effort=${effort ?? '-'} ` + + `max_turns=${cfg.max_turns ?? 'null'} ` + + `allowed_tools_count=${cfg.allowed_tools?.length ?? 0}`, + ) + + // 6. Write payload to /tmp/job-/payload.json. + const payloadDir = `/tmp/job-${jobId}` + const payloadPath = `${payloadDir}/payload.json` + mkdirSync(payloadDir, { recursive: true }) + const payloadJson = JSON.stringify(ctx, null, 2) + writeFileSync(payloadPath, payloadJson, 'utf8') + log( + `payload written path=${payloadPath} size_bytes=${Buffer.byteLength(payloadJson)}`, + ) + + // 7. Build CLI args. + const promptText = getKindPromptText(ctx.kind).replace('$PAYLOAD_PATH', payloadPath) + const args: string[] = [ + '-p', + promptText, + '--model', + cfg.model, + '--permission-mode', + cfg.permission_mode, + '--allowedTools', + (cfg.allowed_tools ?? []).join(','), + '--mcp-config', + MCP_CONFIG, + '--add-dir', + '/opt/agent', + '--output-format', + 'text', + ] + if (effort) args.push('--effort', effort) + + const cwd = worktreePath ?? '/opt/agent' + // Log args zonder de volledige prompt-tekst (kan kilo's groot zijn). + const argsForLog = args + .map((a, i) => (i === 1 ? `` : a)) + .join(' ') + log(`spawn claude job_id=${jobId} cwd=${cwd} args="${argsForLog}"`) + + // 8. Lease-renewal heartbeat for SPRINT_IMPLEMENTATION. + let heartbeatTimer: NodeJS.Timeout | null = null + if (ctx.kind === 'SPRINT_IMPLEMENTATION') { + heartbeatTimer = setInterval(() => { + prisma + .$executeRaw`UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId}` + .then(() => { + log( + `heartbeat tick job_id=${jobId} lease_until=${new Date( + Date.now() + 5 * 60_000, + ).toISOString()}`, + ) + }) + .catch((err: Error) => { + logError(`heartbeat error: ${err.message}`) + }) + }, HEARTBEAT_INTERVAL_MS) + } + + // 9. Spawn Claude. + const start = Date.now() + let exitCode: number | null = null + let stdoutBuf = '' + try { + await new Promise((resolve, reject) => { + const child = spawn('claude', args, { cwd }) + child.stdout.on('data', (chunk) => { + const s = chunk.toString() + process.stdout.write(s) + stdoutBuf += s + }) + child.stderr.on('data', (chunk) => { + const s = chunk.toString() + process.stderr.write(s) + stdoutBuf += s + }) + child.on('error', (err) => reject(err)) + child.on('close', (code) => { + exitCode = code + resolve() + }) + }) + } catch (err) { + logError(`spawn error: ${(err as Error).message}`) + if (heartbeatTimer) clearInterval(heartbeatTimer) + await rollbackClaim(jobId).catch(() => {}) + await releaseLocksOnTerminal(jobId).catch(() => {}) + return 1 + } finally { + if (heartbeatTimer) clearInterval(heartbeatTimer) + } + + const durationMs = Date.now() - start + log( + `claude done job_id=${jobId} exit_code=${exitCode ?? 'null'} ` + + `duration_ms=${durationMs} wall_clock_seconds=${Math.round(durationMs / 1000)}`, + ) + + // 10. Token-expiry detection. + let tokenExpired = false + for (const pat of TOKEN_EXPIRY_PATTERNS) { + if (pat.test(stdoutBuf)) { + tokenExpired = true + log(`TOKEN_EXPIRED detected pattern="${pat.source}" exiting code=3`) + break + } + } + + // 11. Rollback claim if Claude exited non-zero without updating job-status. + // PBI-50 lease-driven recovery vangt resterende stale jobs op na 5 min. + if (exitCode !== 0 && !tokenExpired) { + const jobNow = await prisma.claudeJob.findUnique({ + where: { id: jobId }, + select: { status: true }, + }) + if (jobNow?.status === 'CLAIMED' || jobNow?.status === 'RUNNING') { + log( + `rollback claim job_id=${jobId} reason=claude_exit_${exitCode}_without_status_update`, + ) + await rollbackClaim(jobId).catch(() => {}) + await releaseLocksOnTerminal(jobId).catch(() => {}) + } + } + + // 12. Cleanup payload directory. + try { + rmSync(payloadDir, { recursive: true, force: true }) + } catch { + // non-fatal + } + log(`cleanup payload_removed=true heartbeat_stopped=${heartbeatTimer !== null}`) + + if (tokenExpired) return 3 + return exitCode ?? 1 +} + +// ----- entry ---------------------------------------------------------- +process.on('SIGTERM', () => { + prisma.$disconnect().finally(() => process.exit(143)) +}) + +main() + .then(async (code) => { + log(`exit code=${code}`) + await prisma.$disconnect().catch(() => {}) + process.exit(code) + }) + .catch(async (err) => { + logError(`fatal: ${(err as Error).stack ?? err}`) + await prisma.$disconnect().catch(() => {}) + process.exit(1) + })