diff --git a/__tests__/lib/job-config.test.ts b/__tests__/lib/job-config.test.ts new file mode 100644 index 0000000..16b90b5 --- /dev/null +++ b/__tests__/lib/job-config.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { + getKindDefault, + resolveJobConfig, + mapBudgetToEffort, +} from '@/lib/job-config' + +describe('mapBudgetToEffort', () => { + it.each([ + [0, null], + [-1, null], + [1, 'medium'], + [3000, 'medium'], + [6000, 'medium'], + [6001, 'high'], + [9000, 'high'], + [12000, 'high'], + [12001, 'xhigh'], + [18000, 'xhigh'], + [24000, 'xhigh'], + [24001, 'max'], + [50000, 'max'], + [100000, 'max'], + ])('budget %i → %s', (budget, expected) => { + expect(mapBudgetToEffort(budget)).toBe(expected) + }) +}) + +describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => { + it('TASK_IMPLEMENTATION bevat geen claim-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context') + }) + + it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan') + expect(cfg.allowed_tools).toContain('Bash') + expect(cfg.allowed_tools).toContain('Edit') + expect(cfg.allowed_tools).toContain('Write') + }) + + it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => { + const cfg = getKindDefault('SPRINT_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat') + }) + + it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_GRILL') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_MAKE_PLAN') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('alle kinds hebben non-null allowed_tools', () => { + for (const kind of [ + 'IDEA_GRILL', + 'IDEA_MAKE_PLAN', + 'PLAN_CHAT', + 'TASK_IMPLEMENTATION', + 'SPRINT_IMPLEMENTATION', + ]) { + const cfg = getKindDefault(kind) + expect(cfg.allowed_tools).not.toBeNull() + expect(Array.isArray(cfg.allowed_tools)).toBe(true) + } + }) +}) + +describe('resolveJobConfig — cascade (regression)', () => { + it('task.requires_opus overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_model: 'claude-sonnet-4-6' }, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('product.preferred_permission_mode overrult bypassPermissions', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_permission_mode: 'acceptEdits' }, + ) + expect(cfg.permission_mode).toBe('acceptEdits') + }) +}) diff --git a/docs/INDEX.md b/docs/INDEX.md index 54a1e1e..c9ab542 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-08 from front-matter and headings. +Auto-generated on 2026-05-09 from front-matter and headings. ## Architecture Decision Records @@ -50,6 +50,7 @@ Auto-generated on 2026-05-08 from front-matter and headings. | [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — | | [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | +| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | | [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 | @@ -127,10 +128,10 @@ Auto-generated on 2026-05-08 from front-matter and headings. | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | | [Deploy-controle: triggers, labels, path-filter](./runbooks/deploy-control.md) | `runbooks/deploy-control.md` | active | 2026-05-07 | | [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 | -| [Job-model-selectie per ClaudeJob-kind](./runbooks/job-model-selection.md) | `runbooks/job-model-selection.md` | active | 2026-05-08 | +| [Job-model-selectie per ClaudeJob-kind](./runbooks/job-model-selection.md) | `runbooks/job-model-selection.md` | active | 2026-05-09 | | [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-08 | | [v1.0 Smoke Test Checklist](./runbooks/v1-smoke-test.md) | `runbooks/v1-smoke-test.md` | active | 2026-05-04 | -| [Worker idempotency & job-status protocol](./runbooks/worker-idempotency.md) | `runbooks/worker-idempotency.md` | active | 2026-05-05 | +| [Worker idempotency & job-status protocol](./runbooks/worker-idempotency.md) | `runbooks/worker-idempotency.md` | active | 2026-05-09 | | [StoryDialog Profiel](./story-dialog.md) | `story-dialog.md` | active | 2026-05-03 | | [TaskDialog Profiel](./task-dialog.md) | `task-dialog.md` | active | 2026-05-03 | | [Scrum4Me — API Test Plan](./test-plan.md) | `test-plan.md` | active | 2026-05-03 | diff --git a/docs/plans/queue-loop-extraction.md b/docs/plans/queue-loop-extraction.md new file mode 100644 index 0000000..1ec71de --- /dev/null +++ b/docs/plans/queue-loop-extraction.md @@ -0,0 +1,349 @@ +# Queue-loop verplaatsen van Claude naar runner + +## Context + +Vandaag draait `scrum4me-docker/bin/run-agent.sh` één lange `claude -p`-sessie met de seed-prompt _"draai queue leeg"_. Claude roept zelf herhaaldelijk `wait_for_job` aan binnen die ene CLI-invocation. Het probleem: alle jobs in zo'n run gebruiken dezelfde CLI-flags — terwijl PBI-67 (`lib/job-config.ts`) per job een ander model, permission-mode, thinking-budget en allowed-tools voorschrijft. Een `IDEA_MAKE_PLAN` job moet met Opus + plan-mode draaien, een `TASK_IMPLEMENTATION` met Sonnet + bypassPermissions; binnen één Claude-proces is die switch niet te maken. + +Doel: **één Claude-invocation per geclaimde job**. De runner (buiten Claude) claimt, leest `job.config`, bouwt de juiste CLI-flags, spawnt `claude -p` voor precies die ene job, wacht op exit, claimt de volgende. Claude zelf doet niet meer aan claim-management. + +## Kritische CLI-correctie (vóór alles) + +`claude --version` op het host-systeem en in de Docker base = **2.1.132**. Geverifieerde flag-set: + +| Beschreven in runbook | Bestaat | Vervanging | +|---|---|---| +| `--thinking-budget ` | ❌ | `--effort {low,medium,high,xhigh,max}` | +| `--max-turns ` | ❌ | géén equivalent (gebruik `--max-budget-usd` als budget-cap of laat cosmetisch) | +| `--model`, `--permission-mode`, `--allowedTools`, `--mcp-config`, `--output-format` | ✅ | ongewijzigd | + +`docs/runbooks/worker-idempotency.md:113-150` documenteert flags die niet bestaan. PBI-67-velden `thinking_budget` en `max_turns` zijn vandaag al cosmetisch (de seed-prompt-loop geeft ze ook niet door). Deze refactor is het natuurlijke moment om dat goed te zetten. + +**Beslissing**: voeg `mapBudgetToEffort(budget: number): string | null` toe in beide `job-config.ts`-spiegels: +- `0` → `null` (flag niet meegeven) +- `1..6000` → `"medium"` +- `6001..12000` → `"high"` +- `12001..24000` → `"xhigh"` +- `>24000` → `"max"` + +`max_turns` blijft audit-only — comment in `KIND_DEFAULTS` toevoegen, runner negeert het. + +## Architectuur in één pagina + +``` +run-agent.sh (daemon, backoff, health, log-rotation, token-expiry detectie) + └── tsx /opt/agent/bin/run-one-job.ts ← één iteratie = één job + ├── 1. quota-probe (was Claude's verantwoordelijkheid) + ├── 2. resetStaleClaimedJobs(userId) + ├── 3. tryClaimJob(userId, tokenId) + │ └── null? LISTEN scrum4me_changes met deadline 270s; bij timeout → exit 0 + ├── 4. getFullJobContext(jobId) ← public export uit scrum4me-mcp + ├── 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION) + ├── 6. Schrijf payload → /tmp/job-/payload.json + ├── 7. Bouw CLI-args uit config + map effort + ├── 8. setInterval(60s) lease-renewal ← alleen SPRINT_IMPLEMENTATION + ├── 9. spawnSync('claude', [...]); cwd = worktree_path + ├── 10. try/finally rollbackClaim + releaseLocksOnTerminal als spawn faalt + ├── 11. clearInterval; await prisma.$disconnect() + └── 12. exit met claude's code (3 = TOKEN_EXPIRED) +``` + +Claude zelf: +- krijgt **geen** `wait_for_job` in `--allowedTools` — vangrail tegen recursieve claims. +- krijgt **geen** "draai queue leeg"-prompt meer — per kind een eigen prompt-template. +- doet alleen job-uitvoering: tool-calls voor logging, status-updates, verify, en `update_job_status` aan het einde. + +## Hoe `run-one-job.ts` aan jobId + config komt + +Vier stappen, allemaal binnen het Node-proces — geen aparte CLI-call, geen MCP-stdio-roundtrip. + +### 1. Wie ben ik (auth) + +```ts +import { getAuth } from '/opt/scrum4me-mcp/src/auth.js' +const { userId, tokenId } = await getAuth() +``` + +`getAuth()` (scrum4me-mcp/src/auth.ts:11-40) hashed `process.env.SCRUM4ME_TOKEN` (SHA-256), zoekt in `ApiToken` op `token_hash`, en returnt `{ userId, tokenId, username, isDemo }`. Token komt uit Docker compose `.env`. + +### 2. Welke job (claim) + +```ts +import { tryClaimJob, resetStaleClaimedJobs } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' + +await resetStaleClaimedJobs(userId) // requeu/FAIL stale CLAIMED-jobs (lease verlopen) +const jobId: string | null = await tryClaimJob(userId, tokenId) +``` + +`tryClaimJob` (scrum4me-mcp/src/tools/wait-for-job.ts:358-447) doet één atomic transactie: +1. `SELECT cj.id FROM claude_jobs cj LEFT JOIN tasks t ... LEFT JOIN sprint_runs sr ... WHERE user_id = $userId AND status = 'QUEUED' AND (kind IN idea-kinds OR (kind IN task/sprint AND sprint_run.status IN QUEUED|RUNNING)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE OF cj SKIP LOCKED` +2. `UPDATE claude_jobs SET status='CLAIMED', claimed_by_token_id=$tokenId, claimed_at=NOW(), plan_snapshot=..., lease_until=NOW()+INTERVAL '5 minutes' WHERE id=$jobId` +3. Optioneel: SprintRun QUEUED → RUNNING bij eerste claim. + +`FOR UPDATE SKIP LOCKED` garandeert dat parallelle workers nooit dezelfde job pakken — concurrency-safety op DB-niveau. + +Bij `null`: `LISTEN scrum4me_changes` met deadline 270s; bij notify op `claude_job_enqueued`-event opnieuw `tryClaimJob`. Bij timeout exit 0 (run-agent.sh sleept 2s en herstart). + +### 3. Welke config (resolve op claim-moment) + +```ts +import { getFullJobContext } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' // export-fix in Fase 1 +const ctx = await getFullJobContext(jobId) +``` + +`getFullJobContext` (scrum4me-mcp/src/tools/wait-for-job.ts:449-788) doet **één Prisma-findUnique met joins** (task → story → pbi/sprint, idea, product met `preferred_*`-velden), en roept dan `resolveJobConfig(...)` aan: + +```ts +const config = resolveJobConfig( + { + kind: job.kind, + requested_model: job.requested_model, // snapshot bij enqueue + requested_thinking_budget: job.requested_thinking_budget, + requested_permission_mode: job.requested_permission_mode, + }, + { + preferred_model: job.product.preferred_model, // product-override + thinking_budget_default: job.product.thinking_budget_default, + preferred_permission_mode: job.product.preferred_permission_mode, + }, + job.task ? { requires_opus: job.task.requires_opus } : undefined, +) +``` + +`resolveJobConfig` ([lib/job-config.ts:97-124](../../lib/job-config.ts)) past de override-cascade toe (eerste match wint): +1. `task.requires_opus === true` → `model = 'claude-opus-4-7'` +2. `job.requested_*` (al ingevuld bij enqueue door [lib/job-config-snapshot.ts](../../lib/job-config-snapshot.ts) in de Next.js webapp) +3. `product.preferred_*` +4. `KIND_DEFAULTS[kind]` + +Resultaat zit in `ctx.config`: +```ts +ctx.config = { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: 50, // audit-only, geen CLI flag + allowed_tools: ['Read','Edit','Write','Bash','Grep','Glob','mcp__scrum4me__update_task_status', ...], +} +``` + +Plus de kind-specifieke velden: `task`, `story`, `pbi`, `sprint`, `idea`, `product`, `worktree_path`, `branch_name`, `task_executions[]` (sprint), `prompt_text` (idea). + +### 4. Bouw CLI-args en spawn + +```ts +import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js' +import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js' + +const promptText = getKindPromptText(ctx.kind).replace('$PAYLOAD_PATH', payloadPath) + +const args = [ + '-p', promptText, + '--model', ctx.config.model, + '--permission-mode', ctx.config.permission_mode, + '--allowedTools', ctx.config.allowed_tools.join(','), + '--mcp-config', '/opt/agent/mcp-config.json', + '--add-dir', '/opt/agent', + '--output-format', 'text', +] +const effort = mapBudgetToEffort(ctx.config.thinking_budget) +if (effort) args.push('--effort', effort) + +spawnSync('claude', args, { + stdio: 'inherit', + cwd: ctx.worktree_path ?? ctx.primary_worktree_path ?? '/opt/agent', +}) +``` + +### Twee resolver-passages: bewust ontwerp + +``` +[Webapp enqueue] [Runner claim] +actions/createJob tryClaimJob(jobId) + ↓ ↓ +snapshotFromConfig (lib/job-config-snapshot.ts) + getFullJobContext + ↓ + resolveJobConfig + (leest requested_* terug) + ↓ ↓ +DB: claude_jobs.requested_* ctx.config → CLI flags +``` + +De **enqueue-resolver** legt de keuze vast als snapshot in `ClaudeJob.requested_*` (audittrail). De **claim-resolver** leest die snapshot terug — als een operator handmatig `requested_model` heeft gewijzigd tussen enqueue en claim (bv. "ad-hoc Opus voor deze ene story"), wint die wijziging. Bewust ontwerp. + +## Implementatie — 3 fases, 3 PR's + +### Fase 1 — `scrum4me-mcp` (publieke API + prompts + KIND_DEFAULTS) + +**Bestanden:** +- `scrum4me-mcp/src/tools/wait-for-job.ts` — regel 449 `async function getFullJobContext` → `export async function getFullJobContext`. Niets anders aan de body wijzigen. +- `scrum4me-mcp/src/lib/idea-prompts.ts` → hernoemen naar `src/lib/kind-prompts.ts`. Nieuwe export `getKindPromptText(kind: ClaudeJobKind): string` met cases voor alle vijf kinds. Behoud `getIdeaPromptText` als re-export voor back-compat (wait-for-job.ts roept 'm aan). +- `scrum4me-mcp/src/lib/job-config.ts`: + - Voeg `mapBudgetToEffort(budget: number): string | null` toe. + - Update `KIND_DEFAULTS`: + - `TASK_IMPLEMENTATION.allowed_tools` = expliciete lijst zonder `wait_for_job`/`check_queue_empty`/`get_idea_context`. Inhoud: `['Read','Edit','Write','Bash','Grep','Glob', 'mcp__scrum4me__get_claude_context','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__verify_task_against_plan','mcp__scrum4me__update_job_status','mcp__scrum4me__ask_user_question','mcp__scrum4me__get_question_answer','mcp__scrum4me__list_open_questions','mcp__scrum4me__cancel_question','mcp__scrum4me__worker_heartbeat']` + - `SPRINT_IMPLEMENTATION.allowed_tools` = bovenstaande + `mcp__scrum4me__update_task_execution`, `mcp__scrum4me__verify_sprint_task`. **Géén** `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease (zie Fase 2). Claude hoeft hier niet aan te denken. + - `IDEA_GRILL.allowed_tools` = bestaand + `mcp__scrum4me__update_idea_grill_md`, `mcp__scrum4me__log_idea_decision`, `mcp__scrum4me__update_job_status`, `mcp__scrum4me__ask_user_question`, `mcp__scrum4me__get_question_answer`. + - `IDEA_MAKE_PLAN.allowed_tools` = bestaand + `mcp__scrum4me__update_idea_plan_md`, `mcp__scrum4me__log_idea_decision`, `mcp__scrum4me__update_job_status`. + - Comment toevoegen: `max_turns` is audit-only (Claude CLI 2.1.x mist `--max-turns`). `thinking_budget` mapt via `mapBudgetToEffort`. +- **Nieuwe prompts** in `src/prompts/`: + - `task/implementation.md` — single-task workflow, payload via `$PAYLOAD_PATH`, expliciet géén `wait_for_job`, instructie voor verify-gate + `update_job_status` aan het einde. + - `sprint/implementation.md` — sprint-workflow, Claude verwerkt `task_executions[]` sequentieel. Géén heartbeat-instructie nodig: de runner verlengt de lease via setInterval. + - `plan-chat/chat.md` — placeholder voor PLAN_CHAT. +- **Tests** in `__tests__/` of vergelijkbaar: snapshot-test voor `mapBudgetToEffort` en de nieuwe `KIND_DEFAULTS.allowed_tools`-lijsten. + +**Verificatie van Fase 1:** +```bash +cd /Users/janpetervisser/Development/scrum4me-mcp +npm run typecheck && npm test +``` + +### Fase 2 — `scrum4me-docker` (de runner) + +**Bestanden:** +- **Nieuw**: `scrum4me-docker/bin/run-one-job.ts` — implementeert de stappen uit het architectuur-diagram. Imports uit `/opt/scrum4me-mcp/src/`: + - `getAuth` (auth.ts) + - `tryClaimJob`, `resetStaleClaimedJobs`, `attachWorktreeToJob`, `releaseLocksOnTerminal`, `rollbackClaim`, `getFullJobContext` (tools/wait-for-job.ts) + - `prisma` (prisma.ts) + - `mapBudgetToEffort`, type `JobConfig` (lib/job-config.ts) + - `getKindPromptText` (lib/kind-prompts.ts) +- LISTEN-loop: kopie van `wait-for-job.ts:838-889` (270s deadline ipv 300s — ruim binnen `MAX_WAIT_SECONDS`). +- Quota-probe verhuist hierheen: roep `bin/worker-quota-probe.sh` (bestaat al) en `getWorkerSettings` direct via prisma; sleep tot reset bij beneden-quota. Was voorheen Claude's verantwoordelijkheid in CLAUDE.md stappen 0.1-0.5. +- **Lease-renewal voor SPRINT_IMPLEMENTATION**: `setInterval(60_000, () => prisma.$executeRaw\`UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId}\`)`. Stop op spawn-exit (in finally-block). Werkt onafhankelijk van Claude's tool-call-cadans, dus ook tijdens lange synchrone Bash-calls (zoals `npm install`) blijft de lease vers. +- Token-expiry: detecteer Anthropic-auth-errors uit Claude's output én uit eigen Prisma-fouten; exit 3 → run-agent.sh schrijft `TOKEN_EXPIRED` marker. +- Cleanup: `prisma.$disconnect()` in `process.on('SIGTERM'/'exit')` zodat connection-pool niet blijft hangen tussen iteraties. + +- **Refactor**: `scrum4me-docker/bin/run-agent.sh` + - Verwijder regels 43-44 (SEED_PROMPT) en 46-49 (ALLOWED_TOOLS). + - Vervang regels 73-79 (`claude -p ...` aanroep) door: + ```bash + set +e + tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 + exit_code=$? + set -e + ``` + - Behoud: pre-flight token-check, exponential backoff (regels 106-126), UNHEALTHY na 5 fouten, log-rotation, state.json updates. + - Pas regel 87 (token-expiry regex) aan: detecteer ook `exit_code == 3` als trigger naast de stdout-regex. + +- **Update**: `scrum4me-docker/CLAUDE.md` + - Verwijder de "operationele loop"-sectie (Claude doet die niet meer). + - Korte sectie toevoegen: "deze container draait runner-loop in run-one-job.ts; per geclaimde job spawnt 'ie één Claude-invocation met kind-specifieke flags + prompt". + - Behoud: project-conventions die Claude in de worktree-cwd nodig heeft. + +- **Dockerfile**: niets wijzigen — `tsx@4` global is al geïnstalleerd (regel 39), scrum4me-mcp wordt al gecloned (regel 59-63). + +**Verificatie van Fase 2:** +```bash +cd /Users/janpetervisser/Development/scrum4me-docker +docker compose build +docker compose up -d +# Trigger een TASK_IMPLEMENTATION via de webapp (Sonnet + bypassPermissions verwacht) +# Trigger een IDEA_MAKE_PLAN via de webapp (Opus + plan-mode verwacht) +# Logs: docker compose logs -f +# Verifieer dat per job een nieuwe Claude-invocation logt met de juiste --model en --permission-mode +``` + +### Fase 3 — `Scrum4Me` web-app (lib + runbook) + +**Bestanden:** +- [lib/job-config.ts](../../lib/job-config.ts) — spiegel `mapBudgetToEffort` en de KIND_DEFAULTS-updates uit Fase 1. Comment toevoegen over CLI-mapping. +- [docs/runbooks/worker-idempotency.md](../runbooks/worker-idempotency.md) — herschrijf sectie "Config doorgeven aan Claude Code" (regels 113-150): vervang `--thinking-budget` door `--effort` met mapping-tabel, schrap `--max-turns`, voeg toe dat de runner (`bin/run-one-job.ts`) verantwoordelijk is voor flag-bouw en heartbeat (niet Claude meer). +- [docs/runbooks/job-model-selection.md](../runbooks/job-model-selection.md) — voeg note toe dat `max_turns` audit-only is en dat de runner per job spawnt. +- Tests: vitest snapshot voor `mapBudgetToEffort` (zelfde als in scrum4me-mcp Fase 1). + +**Verificatie van Fase 3:** +```bash +npm run verify && npm run build +``` + +## Logging-contract van `run-one-job.ts` + +Alle log-regels gaan naar **stdout** met format ` [run-one-job] `. `run-agent.sh` redirect dit al naar `/var/log/agent/runs/.log`. Eén regel per event, key=value-format zodat het grep-baar blijft. + +**Verplichte events:** + +| Moment | Voorbeeld-regel | +|---|---| +| Quota-probe start/eind | `2026-05-08T13:11:40Z [run-one-job] quota probe ok used_pct=42 limit_pct=90` | +| Claim attempt start | `2026-05-08T13:11:41Z [run-one-job] claim attempt user_id=usr_… token_id=tok_…` | +| Claim resultaat | `2026-05-08T13:11:42Z [run-one-job] claimed job_id=cl_abc kind=TASK_IMPLEMENTATION task_id=t_xyz product_id=prd_…` of `claim timeout after 270s — exiting 0` | +| **Resolved config** (verplicht) | `2026-05-08T13:11:42Z [run-one-job] config job_id=cl_abc model=claude-sonnet-4-6 permission_mode=bypassPermissions thinking_budget=6000 effort=medium max_turns=50 allowed_tools_count=20 source=kind_default` (waar `source` ∈ `kind_default` / `product_override` / `task_requires_opus` / `job_snapshot` — bepaald door welke override-laag gewonnen heeft) | +| Worktree + payload | `2026-05-08T13:11:42Z [run-one-job] worktree path=/home/agent/.scrum4me-agent-worktrees/cl_abc branch=feat/story-12345678 base_sha=abc123ef` | +| Payload-pad | `2026-05-08T13:11:42Z [run-one-job] payload written path=/tmp/job-cl_abc/payload.json size_bytes=2456` | +| **Claude spawn start** (verplicht) | `2026-05-08T13:11:43Z [run-one-job] spawn claude job_id=cl_abc cwd=/home/agent/.scrum4me-agent-worktrees/cl_abc args="--model claude-sonnet-4-6 --permission-mode bypassPermissions --effort medium --allowedTools <…> --mcp-config /opt/agent/mcp-config.json --add-dir /opt/agent --output-format text"` | +| Lease-renewal tick (alleen SPRINT) | `2026-05-08T13:12:43Z [run-one-job] heartbeat tick job_id=cl_abc lease_until=2026-05-08T13:17:43Z` (bij errors: `heartbeat error: `) | +| **Claude spawn end** (verplicht) | `2026-05-08T13:14:21Z [run-one-job] claude done job_id=cl_abc exit_code=0 duration_ms=158234 wall_clock_seconds=158` | +| Cleanup | `2026-05-08T13:14:21Z [run-one-job] cleanup payload_removed=true prisma_disconnected=true heartbeat_stopped=true` | +| Process exit | `2026-05-08T13:14:21Z [run-one-job] exit code=0 job_id=cl_abc` | + +**Foutpaden ook expliciet:** +- `claim error ` (DB-fout vóór claim) +- `getFullJobContext error job_id=cl_abc ` → triggert rollbackClaim + log `rollback claim job_id=cl_abc reason=context_fetch_failed` +- `attachWorktreeToJob error job_id=cl_abc ` → idem +- `spawn error ` → process kon `claude` niet starten +- Detected token-expiry: `2026-05-08T13:11:43Z [run-one-job] TOKEN_EXPIRED detected pattern="" exiting code=3` + +**Implementatie-helper** in `run-one-job.ts`: +```ts +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}`) +``` + +Geen JSON-logger of structured logging library — `run-agent.sh` parsed niets. Plain text houdt het grep-baar en consistent met de bestaande `_lib.sh`-`log()` shell-helper. + +## Crash-veiligheid + +| Failure-mode | Detectie | Recovery | +|---|---|---| +| Claim gelukt, runner crasht vóór spawn | `lease_until < NOW()` | `resetStaleClaimedJobs` (5 min) — automatisch | +| Spawn faalt (exit ≠ 0 vóór `update_job_status`) | exit code | `try/finally rollbackClaim + releaseLocksOnTerminal` in run-one-job | +| Claude crasht mid-run | exit code | rollbackClaim uit run-one-job; `update_job_status('failed')` is dan optional retry door operator | +| Token-expiry tijdens run | regex op claude-stdout + exit 3 | runner exit 3 → run-agent.sh schrijft TOKEN_EXPIRED marker → container blijft hangen voor diagnose | +| Runner-heartbeat faalt (DB onbereikbaar tijdens setInterval-tick) | error log + lease_until verstrijkt | resetStaleClaimedJobs requeu't (PBI-50 lease-driven recovery, 5 min). Mitigatie: log de Prisma-error in run-one-job zodat het opvalt in run-logs | + +## Verificatie end-to-end + +```bash +# 1. Build alle drie de repos +(cd ~/Development/scrum4me-mcp && npm run typecheck && npm test) +(cd ~/Development/Scrum4Me/.claude/worktrees/festive-jackson-78c3ff && npm run verify && npm run build) +(cd ~/Development/scrum4me-docker && docker compose build) + +# 2. Lokale Docker-run +docker compose up -d +docker compose logs -f agent & + +# 3. Smoke-test scenario's (via webapp of direct via prisma seed): +# a. enqueue IDEA_GRILL → verifieer log toont --model=claude-sonnet-4-6 + --permission-mode=plan + --effort=high +# b. enqueue IDEA_MAKE_PLAN → verifieer --model=claude-opus-4-7 + --effort=max +# c. enqueue TASK_IMPLEMENTATION → verifieer --model=claude-sonnet-4-6 + --permission-mode=bypassPermissions +# d. enqueue task met requires_opus=true → verifieer --model=claude-opus-4-7 +# e. enqueue product met preferred_permission_mode='acceptEdits' → verifieer dat override doorkomt + +# 4. Verifieer in DB na elke run: +# SELECT id, kind, status, requested_model, model_id, requested_permission_mode FROM claude_jobs ORDER BY created_at DESC LIMIT 5; +# requested_model en model_id moeten matchen (tenzij Claude zelf een ander rapporteert) + +# 5. Verifieer queue-loop met meerdere jobs: +# Vul de queue met 3 verschillende kinds; observeer in logs dat per job een nieuwe spawn gebeurt met andere flags. +``` + +## Niet-doelen + +- Geen wijzigingen aan de MCP-tool-set (`wait_for_job` blijft beschikbaar voor handmatige dev-mode; alleen niet meer in Claude's `allowedTools` voor docker-runs). +- Geen herstructurering van het ClaudeJob Prisma-schema. +- Geen wijzigingen aan `lib/job-config-snapshot.ts` (enqueue-laag) — die werkt al goed; deze refactor zit volledig aan de claim/exec-kant. +- Geen migratie van de `wait_for_job`-tool naar HTTP/REST — direct-import is voldoende. + +## Critical files + +- /Users/janpetervisser/Development/scrum4me-mcp/src/tools/wait-for-job.ts (export-fix + ref) +- /Users/janpetervisser/Development/scrum4me-mcp/src/lib/job-config.ts (KIND_DEFAULTS + mapBudgetToEffort) +- /Users/janpetervisser/Development/scrum4me-mcp/src/lib/idea-prompts.ts (rename → kind-prompts.ts) +- /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/task/implementation.md (nieuw) +- /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/sprint/implementation.md (nieuw) +- /Users/janpetervisser/Development/scrum4me-docker/bin/run-one-job.ts (nieuw) +- /Users/janpetervisser/Development/scrum4me-docker/bin/run-agent.sh (refactor) +- /Users/janpetervisser/Development/scrum4me-docker/CLAUDE.md (operationele loop sectie weg) +- lib/job-config.ts (spiegel) +- docs/runbooks/worker-idempotency.md (CLI-flag fix) diff --git a/docs/runbooks/job-model-selection.md b/docs/runbooks/job-model-selection.md index aa67480..689e36d 100644 --- a/docs/runbooks/job-model-selection.md +++ b/docs/runbooks/job-model-selection.md @@ -3,7 +3,7 @@ title: "Job-model-selectie per ClaudeJob-kind" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-09 when_to_read: "Vóór het wijzigen van model/thinking/permission-mode-keuze of bij debugging van 'verkeerd model gebruikt'-incidents." --- @@ -45,6 +45,21 @@ altijd kind-default — geen product- of task-override. | `TASK_IMPLEMENTATION` | `claude-sonnet-4-6` | 6 000 | `bypassPermissions` | 50 | (alle) | | `SPRINT_IMPLEMENTATION` | `claude-sonnet-4-6` | 6 000 | `bypassPermissions` | (geen) | (alle) | +**Note over `max_turns`** (sinds queue-loop-refactor): dit veld blijft +audit-only. Claude CLI 2.1.x heeft géén `--max-turns` flag — de waarde +wordt gesnapshot in `ClaudeJob.requested_*` voor cost-attribution maar +niet doorgegeven aan Claude. Zie +[worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67). + +**Note over `thinking_budget`**: de CLI heeft alleen `--effort +{low,medium,high,xhigh,max}`. De runner mapt het numerieke budget naar +de juiste effort-laag via `mapBudgetToEffort()` in `lib/job-config.ts`. + +**Note over `allowed_tools`**: de defaults sluiten bewust +`mcp__scrum4me__wait_for_job`, `check_queue_empty` en +`get_idea_context` uit. De runner claimt voor Claude — vangrail tegen +recursieve claims binnen één invocation. + **`bypassPermissions`** is verdedigbaar voor de implement-kinds omdat elke run in een geïsoleerde git-worktree start (zie [branch-and-commit.md](./branch-and-commit.md)). Productie-product? @@ -82,6 +97,25 @@ controleer de worker-script tegen de flag-tabel in --- +## Runner-architectuur (sinds 2026-05-09 queue-loop-refactor) + +`scrum4me-docker/bin/run-one-job.ts` draait **één Claude-invocation per +geclaimde job**. De runner leest de `config` uit de +`wait_for_job`-payload (resolved via deze module) en bouwt +kind-specifieke CLI-flags. Voor de exacte flag-mapping (incl. `--effort` +en `--allowedTools`) zie +[worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67). + +Praktische gevolgen: + +- Per job-spawn een nieuwe Claude-invocation met andere flags — geen + config-mismatch tussen jobs in dezelfde queue-run. +- Verschillen tussen `requested_model` en `model_id` in admin → Jobs + duiden óf op handmatige DB-overrides tussen enqueue en claim, óf op + Claude die zelf een ander model rapporteerde (bv. fallback bij + overload). +- Plan: [queue-loop-extraction.md](../plans/queue-loop-extraction.md). + ## Cost-attribution Thinking-tokens worden bij Anthropic-billing gerekend tegen de diff --git a/docs/runbooks/worker-idempotency.md b/docs/runbooks/worker-idempotency.md index aab7578..e4ae759 100644 --- a/docs/runbooks/worker-idempotency.md +++ b/docs/runbooks/worker-idempotency.md @@ -3,7 +3,7 @@ title: "Worker idempotency & job-status protocol" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-05 +last_updated: 2026-05-09 when_to_read: "Vóór het implementeren of debuggen van Claude-CLI-worker logica die `update_job_status` aanroept." --- @@ -113,43 +113,71 @@ Drie protocol-overtredingen die we met deze runbook + de nieuwe ## Config doorgeven aan Claude Code (PBI-67) `wait_for_job` levert sinds PBI-67 een `config`-object mee in de -response. Geef deze door aan `claude` als CLI-flags: +response. **De runner** (`scrum4me-docker/bin/run-one-job.ts`) leest deze +config en bouwt per geclaimde job de juiste Claude CLI-flags. Eén +Claude-invocation per job — niet één lange sessie die zelf claimt. ```bash claude \ + -p "$PROMPT" \ --model "$MODEL" \ --permission-mode "$PERMISSION_MODE" \ - --thinking-budget "$THINKING_BUDGET" \ - ${MAX_TURNS:+--max-turns $MAX_TURNS} \ - ${ALLOWED_TOOLS:+--allowed-tools "$ALLOWED_TOOLS"} + ${EFFORT:+--effort $EFFORT} \ + --allowedTools "$ALLOWED_TOOLS" \ + --mcp-config /opt/agent/mcp-config.json \ + --add-dir /opt/agent \ + --output-format text ``` Waar: | Variabele | Bron in response | Voorbeeld | |---|---|---| +| `PROMPT` | `getKindPromptText(kind)` met `$PAYLOAD_PATH` vervangen | (kind-prompt-md) | | `MODEL` | `config.model` | `claude-sonnet-4-6` | | `PERMISSION_MODE` | `config.permission_mode` | `bypassPermissions` | -| `THINKING_BUDGET` | `config.thinking_budget` (0 = uit) | `12000` | -| `MAX_TURNS` | `config.max_turns` (null = onbegrensd) | `15` of leeg | -| `ALLOWED_TOOLS` | `config.allowed_tools.join(',')` (null = alle) | `Read,Grep,WebSearch` | +| `EFFORT` | `mapBudgetToEffort(config.thinking_budget)` (null = vlag weg) | `medium` | +| `ALLOWED_TOOLS` | `config.allowed_tools.join(',')` | `Read,Edit,…,mcp__scrum4me__update_task_status,…` | -Verwachte CLI-aanroep per kind (kind-defaults zonder overrides): +### Claude CLI 2.1.x flag-correctie -| Kind | Model | thinking | permission_mode | max_turns | +De Claude CLI 2.1.x heeft géén numerieke `--thinking-budget` en géén +`--max-turns`. Mapping: + +| `config.thinking_budget` | CLI-flag | +|---|---| +| 0 | (geen `--effort` flag) | +| 1-6000 | `--effort medium` | +| 6001-12000 | `--effort high` | +| 12001-24000 | `--effort xhigh` | +| >24000 | `--effort max` | + +`config.max_turns` blijft **audit-only** — wordt gesnapshot in +`ClaudeJob.requested_*` voor cost-attribution maar niet doorgegeven aan +Claude. De resolver in `lib/job-config.ts` exporteert +`mapBudgetToEffort(budget)` voor deze mapping. + +### Verwachte CLI-aanroep per kind (defaults zonder overrides) + +| Kind | Model | thinking_budget | --effort | permission_mode | |---|---|---|---|---| -| `IDEA_GRILL` | sonnet-4-6 | 12000 | plan | 15 | -| `IDEA_MAKE_PLAN` | opus-4-7 | 24000 | plan | 20 | -| `PLAN_CHAT` | sonnet-4-6 | 6000 | plan | 5 | -| `TASK_IMPLEMENTATION` | sonnet-4-6 | 6000 | bypassPermissions | 50 | -| `SPRINT_IMPLEMENTATION` | sonnet-4-6 | 6000 | bypassPermissions | (geen) | +| `IDEA_GRILL` | sonnet-4-6 | 12000 | high | plan | +| `IDEA_MAKE_PLAN` | opus-4-7 | 24000 | xhigh | plan | +| `PLAN_CHAT` | sonnet-4-6 | 6000 | medium | plan | +| `TASK_IMPLEMENTATION` | sonnet-4-6 | 6000 | medium | bypassPermissions | +| `SPRINT_IMPLEMENTATION` | sonnet-4-6 | 6000 | medium | bypassPermissions | -**Onbekende flag:** als de huidige Claude Code-versie een vlag niet -kent, log een waarschuwing en sla 'm over — geen hard error. De server -blijft jobs queuen. +### Wie doet wat in de runner-architectuur + +| Component | Verantwoordelijkheid | +|---|---| +| `bin/run-agent.sh` | Daemon-loop, exponential backoff, UNHEALTHY-marker, log-rotation, TOKEN_EXPIRED-detectie via exit-code 3 of stdout-regex | +| `bin/run-one-job.ts` | **Claim** (tryClaimJob + LISTEN-fallback 270s), config-resolve (getFullJobContext), payload schrijven, **CLI-flags bouwen**, spawn `claude`, lease-renewal voor SPRINT (setInterval 60s), rollbackClaim bij Claude exit≠0 zonder update_job_status, cleanup | +| `claude` (per invocation) | Voert exclusief de geclaimde job uit. **Mag geen** `wait_for_job`, `check_queue_empty`, of `job_heartbeat` aanroepen — zit niet in `allowed_tools` | Volledige resolver-uitleg + override-cascade staat in -[job-model-selection.md](./job-model-selection.md). +[job-model-selection.md](./job-model-selection.md). Refactor-plan: +[queue-loop-extraction.md](../plans/queue-loop-extraction.md). --- diff --git a/lib/job-config.ts b/lib/job-config.ts index a3c07ff..3347c1d 100644 --- a/lib/job-config.ts +++ b/lib/job-config.ts @@ -9,6 +9,13 @@ // 2. job.requested_* (snapshot bij enqueue, ingevuld door deze module) // 3. product.preferred_* // 4. KIND_DEFAULTS hieronder +// +// CLI-flag-mapping (Claude CLI 2.1.x): +// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max} +// (de CLI heeft geen --thinking-budget flag — alleen --effort) +// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag. +// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven. +// - allowed_tools → --allowedTools (komma-gescheiden lijst) export type ClaudeModel = | 'claude-opus-4-7' @@ -42,20 +49,52 @@ export type TaskInput = { requires_opus?: boolean | null } +// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty` +// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts) +// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation. +const TASK_TOOLS = [ + 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', + 'mcp__scrum4me__get_claude_context', + '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__verify_task_against_plan', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + 'mcp__scrum4me__get_question_answer', + 'mcp__scrum4me__list_open_questions', + 'mcp__scrum4me__cancel_question', + 'mcp__scrum4me__worker_heartbeat', +] + const KIND_DEFAULTS: Record = { IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15, - allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'], + allowed_tools: [ + 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', + 'mcp__scrum4me__update_idea_grill_md', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + 'mcp__scrum4me__get_question_answer', + ], }, IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20, - allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'], + allowed_tools: [ + 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', + 'mcp__scrum4me__update_idea_plan_md', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + ], }, PLAN_CHAT: { model: 'claude-sonnet-4-6', @@ -69,14 +108,20 @@ const KIND_DEFAULTS: Record = { thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50, - allowed_tools: null, + allowed_tools: TASK_TOOLS, }, SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null, - allowed_tools: null, + // Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease + // automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts). + allowed_tools: [ + ...TASK_TOOLS, + 'mcp__scrum4me__update_task_execution', + 'mcp__scrum4me__verify_sprint_task', + ], }, } @@ -123,6 +168,23 @@ export function resolveJobConfig( } } +// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag. +// Returns null als de flag niet meegegeven moet worden (budget = 0). +// +// Mapping (sync met scrum4me-mcp/src/lib/job-config.ts): +// 0 → null (geen --effort flag) +// 1..6000 → "medium" +// 6001..12000 → "high" +// 12001..24000→ "xhigh" +// >24000 → "max" +export function mapBudgetToEffort(budget: number): string | null { + if (budget <= 0) return null + if (budget <= 6000) return 'medium' + if (budget <= 12000) return 'high' + if (budget <= 24000) return 'xhigh' + return 'max' +} + // Snapshot-velden voor ClaudeJob.requested_*. Bij elke enqueue laden we // product (voor preferred_*) en optioneel task (voor requires_opus), draaien // de resolver, en schrijven het resultaat als auditspoor in de job-rij.