diff --git a/.env.example b/.env.example index 62b28f7..6a3e89c 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,3 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname" # API token from Scrum4Me → /settings/tokens SCRUM4ME_TOKEN="" - -# Internal push endpoint (main-app) for web-push notifications -# Set to the main-app /api/internal/push/send URL; leave empty to disable push (feature-gated). -INTERNAL_PUSH_URL="https://scrum4me.example.com/api/internal/push/send" -# Shared secret (≥32 chars) — must match INTERNAL_PUSH_SECRET in the main-app env. -INTERNAL_PUSH_SECRET="" diff --git a/.gitignore b/.gitignore index 547c38e..10a6dab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,3 @@ prisma/generated # Editor .vscode .idea - -# Claude Code worktrees (per-session, never tracked) -.claude/worktrees/ diff --git a/README.md b/README.md index 793cc07..b6027d4 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,6 @@ activity and create todos via native tool calls instead of curl. | `check_queue_empty` | Synchronous, non-blocking count of active jobs (QUEUED/CLAIMED/RUNNING); optional `product_id` scope | no | | `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no | | `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no | -| `verify_sprint_task` | SPRINT_IMPLEMENTATION-flow: compare a `SprintTaskExecution`'s frozen `plan_snapshot` against `git diff ...HEAD`. Returns `verify_result` + `allowed_for_done`. For `task[1..N]` zonder base_sha vult de tool die in op basis van de head_sha van de vorige DONE-execution | yes (read-only) | -| `update_task_execution` | SPRINT_IMPLEMENTATION-flow: mutate `SprintTaskExecution.status` (PENDING/RUNNING/DONE/FAILED/SKIPPED). Token must own the parent SPRINT-job. Idempotent | no | -| `job_heartbeat` | Extend `claude_jobs.lease_until` by 5 min. For SPRINT-jobs: response includes `sprint_run_status` + `sprint_run_pause_reason` so the worker can break its task-loop on UI-side cancel/pause | no | Demo accounts may read but writes return `PERMISSION_DENIED`. @@ -296,10 +293,6 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig): > *Pak de volgende job uit de Scrum4Me-queue.* -## Web-push integration - -When `INTERNAL_PUSH_URL` and `INTERNAL_PUSH_SECRET` are set, the MCP server fires a fire-and-forget push notification to the main-app's internal endpoint (`/api/internal/push/send`) on two events: when `ask_user_question` creates a new question (tag `claude-q-`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-`). Both calls are wrapped in a 5 s `AbortController` timeout and a `try/catch` so a push failure never interrupts the tool response. Omitting the env vars disables the feature entirely. The `INTERNAL_PUSH_SECRET` value must match the one configured in the main-app; generate a fresh secret with `openssl rand -hex 32`. - ## Schema sync The Prisma schema is the source of truth in the upstream Scrum4Me @@ -342,33 +335,3 @@ npx @modelcontextprotocol/inspector node dist/index.js - **Production database** — verify against a preview database before running against prod. The token check enforces user scope but does not gate reads of unrelated products you happen to be a member of. - -## Worktrees - -Scrum4Me-mcp uses git worktrees rooted at `~/.scrum4me-agent-worktrees/` (override via `SCRUM4ME_AGENT_WORKTREE_DIR`). - -### Two kinds of worktrees - -- **Per-job task-worktrees** (`/`) — one per `TASK_IMPLEMENTATION` job. Created at claim, cleaned up on `DONE`/`FAILED`/`CANCELLED` via `cleanup_my_worktrees`. -- **Persistent product-worktrees** (`_products//`) — one per product with `repo_url`, used by `IDEA_GRILL` and `IDEA_MAKE_PLAN`. **Detached HEAD on `origin/main`**, hard-reset at every job start. `.scratch/` holds throw-away work and is wiped on each claim. - -### Concurrency: file-locks - -Product-worktrees are serialised via `proper-lockfile` on `_products/.lock`. Two parallel idea-jobs on the same product wait for each other. For multi-product idea-jobs, locks are acquired in alphabetical order to prevent deadlocks. - -### Single-host invariant - -`proper-lockfile` only works when all MCP-server processes run on the same host. Migrate to Postgres `pg_advisory_lock` when: -- multiple MCP instances on different machines serve workers, or -- the worktree directory is shared over NFS/CIFS. - -Migration path: replace `acquireFileLock` in `src/git/file-lock.ts` with a `pg_try_advisory_lock(hashtext(path)::bigint)` wrapper via the existing Prisma connection. The API stays identical. - -### Manual cleanup - -`cleanup_my_worktrees` skips `_products/` and `*.lock` automatically. To clean up a product-worktree manually (after archive or repo-rename): - -```bash -git worktree remove --force ~/.scrum4me-agent-worktrees/_products/ -rm ~/.scrum4me-agent-worktrees/_products/.lock # if still present -``` diff --git a/__tests__/cancel-pbi-cascade.test.ts b/__tests__/cancel-pbi-cascade.test.ts index e884c9f..8b55688 100644 --- a/__tests__/cancel-pbi-cascade.test.ts +++ b/__tests__/cancel-pbi-cascade.test.ts @@ -285,66 +285,4 @@ describe('cancelPbiOnFailure', () => { expect(out.warnings.some((w) => w.includes('boom'))).toBe(true) }) - - it('no-ops when failed job has status SKIPPED (no-op exit, niet een echte fail)', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) - - const out = await cancelPbiOnFailure('job-failed') - - expect(out.cancelled_job_ids).toEqual([]) - expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled() - expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled() - expect(mockPrisma.claudeJob.update).not.toHaveBeenCalled() - }) - - it('appends the cascade trace to an existing error (preserves original cause)', async () => { - // findUnique wordt twee keer aangeroepen: eerst voor failedJob (status FAILED + originele error), - // daarna door de append-trace om de huidige error te lezen vóór update. - mockPrisma.claudeJob.findUnique - .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) - .mockResolvedValueOnce({ error: 'timeout: agent died after 5min' }) - mockPrisma.claudeJob.findMany.mockResolvedValue([]) - - await cancelPbiOnFailure('job-failed') - - expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'job-failed' }, - data: expect.objectContaining({ - error: expect.stringMatching(/timeout: agent died after 5min[\s\S]*---[\s\S]*cancelled_by_self/), - }), - }), - ) - }) - - it('falls back to trace-only when there is no existing error', async () => { - mockPrisma.claudeJob.findUnique - .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) - .mockResolvedValueOnce({ error: null }) - mockPrisma.claudeJob.findMany.mockResolvedValue([]) - - await cancelPbiOnFailure('job-failed') - - const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as - | { data: { error: string } } - | undefined - expect(updateCall?.data.error).toMatch(/^cancelled_by_self/) - expect(updateCall?.data.error).not.toContain('---') - }) - - it('truncates the merged error at 1900 chars while preserving the head of the original', async () => { - const longOriginal = 'X'.repeat(1800) - mockPrisma.claudeJob.findUnique - .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) - .mockResolvedValueOnce({ error: longOriginal }) - mockPrisma.claudeJob.findMany.mockResolvedValue([]) - - await cancelPbiOnFailure('job-failed') - - const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as - | { data: { error: string } } - | undefined - expect(updateCall?.data.error.length).toBeLessThanOrEqual(1900) - expect(updateCall?.data.error.startsWith('X')).toBe(true) - }) }) diff --git a/__tests__/cleanup-my-worktrees.test.ts b/__tests__/cleanup-my-worktrees.test.ts index 72903f3..6460157 100644 --- a/__tests__/cleanup-my-worktrees.test.ts +++ b/__tests__/cleanup-my-worktrees.test.ts @@ -73,17 +73,6 @@ describe('listWorktreeJobIds', () => { mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([]) }) - - it('skips _products/ system dir and *.lock files (PBI-9)', async () => { - mockReaddir.mockResolvedValue([ - makeDirent('job-aaa'), - makeDirent('_products'), - makeDirent('product-abc.lock'), - makeDirent('job-bbb'), - ]) - const ids = await listWorktreeJobIds(WORKTREE_PARENT) - expect(ids).toEqual(['job-aaa', 'job-bbb']) - }) }) describe('cleanupWorktrees', () => { diff --git a/__tests__/create-sprint.test.ts b/__tests__/create-sprint.test.ts deleted file mode 100644 index 5837d6e..0000000 --- a/__tests__/create-sprint.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { Prisma } from '@prisma/client' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - sprint: { - findMany: vi.fn(), - create: vi.fn(), - }, - }, -})) - -vi.mock('../src/auth.js', () => ({ - requireWriteAccess: vi.fn(), - PermissionDeniedError: class PermissionDeniedError extends Error { - constructor(message = 'Demo accounts cannot perform write operations') { - super(message) - this.name = 'PermissionDeniedError' - } - }, -})) - -vi.mock('../src/access.js', () => ({ - userCanAccessProduct: vi.fn(), -})) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { userCanAccessProduct } from '../src/access.js' -import { handleCreateSprint } from '../src/tools/create-sprint.js' - -const mockPrisma = prisma as unknown as { - sprint: { - findMany: ReturnType - create: ReturnType - } -} -const mockRequireWriteAccess = requireWriteAccess as ReturnType -const mockUserCanAccessProduct = userCanAccessProduct as ReturnType - -const PRODUCT_ID = 'prod-1' -const USER_ID = 'user-1' - -beforeEach(() => { - vi.clearAllMocks() - mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) - mockUserCanAccessProduct.mockResolvedValue(true) - mockPrisma.sprint.findMany.mockResolvedValue([]) -}) - -function parseResult(result: Awaited>) { - const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' - try { return JSON.parse(text) } catch { return text } -} - -describe('handleCreateSprint', () => { - it('happy path: creates sprint with auto-generated code', async () => { - mockPrisma.sprint.create.mockResolvedValue({ - id: 'spr-1', - code: 'S-2026-05-11-1', - sprint_goal: 'My goal', - status: 'OPEN', - start_date: new Date('2026-05-11'), - created_at: new Date('2026-05-11T10:00:00Z'), - }) - - const result = await handleCreateSprint({ - product_id: PRODUCT_ID, - sprint_goal: 'My goal', - }) - - expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) - const callArgs = mockPrisma.sprint.create.mock.calls[0][0] - expect(callArgs.data.product_id).toBe(PRODUCT_ID) - expect(callArgs.data.status).toBe('OPEN') - expect(callArgs.data.sprint_goal).toBe('My goal') - expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/) - expect(callArgs.data.start_date).toBeInstanceOf(Date) - - const parsed = parseResult(result) - expect(parsed.id).toBe('spr-1') - expect(parsed.status).toBe('OPEN') - }) - - it('uses user-provided code when given', async () => { - mockPrisma.sprint.create.mockResolvedValue({ - id: 'spr-2', - code: 'CUSTOM-CODE', - sprint_goal: 'g', - status: 'OPEN', - start_date: new Date(), - created_at: new Date(), - }) - - await handleCreateSprint({ - product_id: PRODUCT_ID, - code: 'CUSTOM-CODE', - sprint_goal: 'g', - }) - - expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) - expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled() - expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE') - }) - - it('auto-code increments past existing same-day sprints', async () => { - // Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt - // alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky. - const today = new Date().toISOString().slice(0, 10) - mockPrisma.sprint.findMany.mockResolvedValue([ - { code: `S-${today}-1` }, - { code: `S-${today}-3` }, - { code: 'S-2020-01-01-7' }, - ]) - mockPrisma.sprint.create.mockResolvedValue({ - id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(), - }) - - await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - - expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`) - }) - - it('retries on P2002 unique conflict', async () => { - const conflict = new Prisma.PrismaClientKnownRequestError('unique', { - code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] }, - }) - mockPrisma.sprint.create - .mockRejectedValueOnce(conflict) - .mockResolvedValueOnce({ - id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN', - start_date: new Date(), created_at: new Date(), - }) - - const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - - expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2) - expect(parseResult(result).id).toBe('spr-r') - }) - - it('returns error when user cannot access product', async () => { - mockUserCanAccessProduct.mockResolvedValue(false) - const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - - expect(mockPrisma.sprint.create).not.toHaveBeenCalled() - const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' - expect(text).toMatch(/not found or not accessible/) - }) - - it('uses provided start_date when given', async () => { - mockPrisma.sprint.create.mockResolvedValue({ - id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN', - start_date: new Date('2026-01-01'), created_at: new Date(), - }) - - await handleCreateSprint({ - product_id: PRODUCT_ID, - sprint_goal: 'g', - start_date: '2026-01-01', - }) - - const callArgs = mockPrisma.sprint.create.mock.calls[0][0] - expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01') - }) -}) diff --git a/__tests__/create-story.test.ts b/__tests__/create-story.test.ts deleted file mode 100644 index 2bf1222..0000000 --- a/__tests__/create-story.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - pbi: { findUnique: vi.fn() }, - sprint: { findUnique: vi.fn() }, - story: { - findFirst: vi.fn(), - findMany: vi.fn(), - create: vi.fn(), - }, - }, -})) - -vi.mock('../src/auth.js', () => ({ - requireWriteAccess: vi.fn(), - PermissionDeniedError: class PermissionDeniedError extends Error { - constructor(message = 'Demo accounts cannot perform write operations') { - super(message) - this.name = 'PermissionDeniedError' - } - }, -})) - -vi.mock('../src/access.js', () => ({ - userCanAccessProduct: vi.fn(), -})) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { userCanAccessProduct } from '../src/access.js' -import { handleCreateStory } from '../src/tools/create-story.js' - -const mockPrisma = prisma as unknown as { - pbi: { findUnique: ReturnType } - sprint: { findUnique: ReturnType } - story: { - findFirst: ReturnType - findMany: ReturnType - create: ReturnType - } -} -const mockRequireWriteAccess = requireWriteAccess as ReturnType -const mockUserCanAccessProduct = userCanAccessProduct as ReturnType - -const PRODUCT_ID = 'prod-1' -const PBI_ID = 'pbi-1' -const SPRINT_ID = 'spr-1' -const USER_ID = 'user-1' - -beforeEach(() => { - vi.clearAllMocks() - mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) - mockUserCanAccessProduct.mockResolvedValue(true) - mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) - mockPrisma.story.findMany.mockResolvedValue([]) - mockPrisma.story.findFirst.mockResolvedValue(null) - mockPrisma.story.create.mockImplementation((args: { data: Record }) => - Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }), - ) -}) - -function parseResult(result: Awaited>) { - const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' - try { return JSON.parse(text) } catch { return text } -} - -function errorText(result: Awaited>): string { - return result.content?.[0]?.type === 'text' ? result.content[0].text : '' -} - -describe('handleCreateStory', () => { - it('without sprint_id: creates story with status OPEN and no sprint', async () => { - const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 }) - - expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() - const data = mockPrisma.story.create.mock.calls[0][0].data - expect(data.status).toBe('OPEN') - expect(data.sprint_id).toBeNull() - expect(data.product_id).toBe(PRODUCT_ID) - expect(parseResult(result).status).toBe('OPEN') - }) - - it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) - - const result = await handleCreateStory({ - pbi_id: PBI_ID, - title: 'A story', - priority: 2, - sprint_id: SPRINT_ID, - }) - - expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({ - where: { id: SPRINT_ID }, - select: { product_id: true }, - }) - const data = mockPrisma.story.create.mock.calls[0][0].data - expect(data.status).toBe('IN_SPRINT') - expect(data.sprint_id).toBe(SPRINT_ID) - expect(parseResult(result).sprint_id).toBe(SPRINT_ID) - }) - - it('rejects a non-existent sprint_id', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(null) - - const result = await handleCreateStory({ - pbi_id: PBI_ID, - title: 'A story', - priority: 2, - sprint_id: 'missing', - }) - - expect(mockPrisma.story.create).not.toHaveBeenCalled() - expect(errorText(result)).toMatch(/Sprint missing not found/) - }) - - it('rejects a sprint from a different product', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' }) - - const result = await handleCreateStory({ - pbi_id: PBI_ID, - title: 'A story', - priority: 2, - sprint_id: SPRINT_ID, - }) - - expect(mockPrisma.story.create).not.toHaveBeenCalled() - expect(errorText(result)).toMatch(/different product/) - }) - - it('returns error when PBI not found', async () => { - mockPrisma.pbi.findUnique.mockResolvedValue(null) - - const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 }) - - expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() - expect(mockPrisma.story.create).not.toHaveBeenCalled() - expect(errorText(result)).toMatch(/PBI missing not found/) - }) -}) diff --git a/__tests__/flow/effects.test.ts b/__tests__/flow/effects.test.ts deleted file mode 100644 index 070e375..0000000 --- a/__tests__/flow/effects.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { executeEffects } from '../../src/flow/effects.js' - -describe('effects executor', () => { - it('RELEASE_WORKTREE_LOCKS for unknown jobId is a no-op (no throw)', async () => { - const out = await executeEffects([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'no-such-job' }]) - expect(out).toEqual([]) - }) - - it('multiple effects execute in order; failure in one is logged but does not abort', async () => { - const out = await executeEffects([ - { type: 'RELEASE_WORKTREE_LOCKS', jobId: 'a' }, - { type: 'RELEASE_WORKTREE_LOCKS', jobId: 'b' }, - ]) - expect(out).toEqual([]) - }) - - it('empty effects array returns empty outcomes', async () => { - const out = await executeEffects([]) - expect(out).toEqual([]) - }) -}) diff --git a/__tests__/flow/pr-flow.test.ts b/__tests__/flow/pr-flow.test.ts deleted file mode 100644 index 2330915..0000000 --- a/__tests__/flow/pr-flow.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { transition, type PrFlowState } from '../../src/flow/pr-flow.js' - -describe('pr-flow STORY-mode 3-tasks scenario', () => { - it('opens PR early; auto-merge only fires on the last task', () => { - let state: PrFlowState = { kind: 'none', strategy: 'STORY' } - const allEffects: Array> = [] - - // Task 1 DONE → PR_CREATED - let r = transition(state, { type: 'PR_CREATED', prUrl: 'https://github.com/o/r/pull/1' }) - state = r.nextState - allEffects.push(...r.effects) - expect(state.kind).toBe('pr_opened') - expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0) - - // Task 2 DONE → no STORY_COMPLETED yet, no transition emitted - r = transition(state, { type: 'TASK_DONE', taskId: 't2', headSha: 'abc123' }) - state = r.nextState - allEffects.push(...r.effects) - expect(state.kind).toBe('pr_opened') - expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0) - - // Task 3 DONE = STORY_COMPLETED → ENABLE_AUTO_MERGE with head guard - r = transition(state, { type: 'STORY_COMPLETED', storyId: 's1', headSha: 'def456' }) - state = r.nextState - allEffects.push(...r.effects) - expect(state.kind).toBe('waiting_for_checks') - const enableEffects = allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE') - expect(enableEffects).toHaveLength(1) - expect(enableEffects[0]).toMatchObject({ expectedHeadSha: 'def456' }) - - // CI green + merge OK - r = transition(state, { type: 'MERGE_RESULT' }) - state = r.nextState - expect(state.kind).toBe('auto_merge_enabled') - }) - - it('CHECKS_FAILED → checks_failed (no pause)', () => { - const state: PrFlowState = { - kind: 'waiting_for_checks', - strategy: 'STORY', - prUrl: 'x', - headSha: 'y', - } - const r = transition(state, { type: 'MERGE_RESULT', reason: 'CHECKS_FAILED' }) - expect(r.nextState.kind).toBe('checks_failed') - }) - - it('MERGE_CONFLICT → merge_conflict_paused', () => { - const state: PrFlowState = { - kind: 'waiting_for_checks', - strategy: 'STORY', - prUrl: 'x', - headSha: 'y', - } - const r = transition(state, { type: 'MERGE_RESULT', reason: 'MERGE_CONFLICT' }) - expect(r.nextState.kind).toBe('merge_conflict_paused') - }) -}) - -describe('pr-flow SPRINT-mode', () => { - it('draft stays draft until SPRINT_COMPLETED → MARK_PR_READY effect', () => { - let state: PrFlowState = { kind: 'none', strategy: 'SPRINT' } - let r = transition(state, { type: 'PR_CREATED', prUrl: 'x' }) - expect(r.nextState.kind).toBe('draft_opened') - expect(r.effects).toHaveLength(0) - - state = r.nextState - r = transition(state, { type: 'TASK_DONE', taskId: 't1', headSha: 'a' }) - expect(r.nextState.kind).toBe('draft_opened') - expect(r.effects).toHaveLength(0) - - state = r.nextState - r = transition(state, { type: 'SPRINT_COMPLETED', sprintRunId: 'sr1' }) - expect(r.nextState.kind).toBe('ready_for_review') - expect(r.effects.filter((e) => e.type === 'MARK_PR_READY')).toHaveLength(1) - }) -}) diff --git a/__tests__/flow/sprint-run.test.ts b/__tests__/flow/sprint-run.test.ts deleted file mode 100644 index 077354e..0000000 --- a/__tests__/flow/sprint-run.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { transition, type SprintRunState } from '../../src/flow/sprint-run.js' - -describe('sprint-run pure transitions', () => { - it('queued + CLAIM_FIRST_JOB → running with SET_SPRINT_RUN_STATUS effect', () => { - const state: SprintRunState = { kind: 'queued', sprintRunId: 'sr1' } - const r = transition(state, { type: 'CLAIM_FIRST_JOB' }) - expect(r.nextState.kind).toBe('running') - expect(r.effects).toEqual([ - { type: 'SET_SPRINT_RUN_STATUS', sprintRunId: 'sr1', status: 'RUNNING' }, - ]) - }) - - it('running + MERGE_CONFLICT → paused_merge_conflict + 2 effects in order', () => { - const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' } - const r = transition(state, { - type: 'MERGE_CONFLICT', - prUrl: 'https://github.com/o/r/pull/1', - prHeadSha: 'abc123', - conflictFiles: ['a.ts', 'b.ts'], - resumeInstructions: 'Resolve and push', - }) - expect(r.nextState.kind).toBe('paused_merge_conflict') - expect(r.effects).toHaveLength(2) - expect(r.effects[0].type).toBe('CREATE_CLAUDE_QUESTION') - expect(r.effects[1].type).toBe('SET_SPRINT_RUN_STATUS') - if (r.effects[1].type === 'SET_SPRINT_RUN_STATUS') { - expect(r.effects[1].status).toBe('PAUSED') - expect(r.effects[1].pauseContextDraft).toMatchObject({ - pause_reason: 'MERGE_CONFLICT', - pr_url: 'https://github.com/o/r/pull/1', - pr_head_sha: 'abc123', - conflict_files: ['a.ts', 'b.ts'], - }) - } - }) - - it('paused + USER_RESUMED → running + CLOSE_CLAUDE_QUESTION + clear pause_context', () => { - const state: SprintRunState = { - kind: 'paused_merge_conflict', - sprintRunId: 'sr1', - pauseContext: { - pause_reason: 'MERGE_CONFLICT', - pr_url: 'x', - pr_head_sha: 'y', - conflict_files: [], - claude_question_id: 'q1', - resume_instructions: 'r', - paused_at: new Date().toISOString(), - }, - } - const r = transition(state, { type: 'USER_RESUMED' }) - expect(r.nextState.kind).toBe('running') - expect(r.effects[0]).toEqual({ type: 'CLOSE_CLAUDE_QUESTION', questionId: 'q1' }) - expect(r.effects[1]).toMatchObject({ - type: 'SET_SPRINT_RUN_STATUS', - status: 'RUNNING', - clearPauseContext: true, - }) - }) - - it('running + TASK_FAILED → failed (no PAUSE)', () => { - const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' } - const r = transition(state, { type: 'TASK_FAILED', taskId: 't1', error: 'CI red' }) - expect(r.nextState.kind).toBe('failed') - expect(r.effects[0]).toMatchObject({ status: 'FAILED' }) - }) - - it('running + ALL_DONE → done + SET_SPRINT_RUN_STATUS DONE', () => { - const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' } - const r = transition(state, { type: 'ALL_DONE' }) - expect(r.nextState.kind).toBe('done') - expect(r.effects[0]).toMatchObject({ status: 'DONE' }) - }) - - it('forbidden transition (running + CLAIM_FIRST_JOB) keeps state and emits no effects', () => { - const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' } - const r = transition(state, { type: 'CLAIM_FIRST_JOB' }) - expect(r.nextState).toEqual(state) - expect(r.effects).toEqual([]) - }) -}) diff --git a/__tests__/flow/worktree-lease.test.ts b/__tests__/flow/worktree-lease.test.ts deleted file mode 100644 index 8cf7e99..0000000 --- a/__tests__/flow/worktree-lease.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { transition, type WorktreeLeaseState } from '../../src/flow/worktree-lease.js' - -describe('worktree-lease pure transitions', () => { - it('idle + JOB_CLAIMED → acquiring_lock, no effects', () => { - const r = transition({ kind: 'idle' }, { type: 'JOB_CLAIMED', jobId: 'j1', productIds: ['p1'] }) - expect(r.nextState.kind).toBe('acquiring_lock') - expect(r.effects).toEqual([]) - }) - - it('acquiring_lock + LOCK_ACQUIRED → creating_or_reusing', () => { - const state: WorktreeLeaseState = { - kind: 'acquiring_lock', - jobId: 'j1', - productIds: ['p1'], - } - const r = transition(state, { type: 'LOCK_ACQUIRED' }) - expect(r.nextState.kind).toBe('creating_or_reusing') - expect(r.effects).toEqual([]) - }) - - it('acquiring_lock + LOCK_TIMEOUT → lock_timeout', () => { - const state: WorktreeLeaseState = { - kind: 'acquiring_lock', - jobId: 'j1', - productIds: ['p1'], - } - const r = transition(state, { type: 'LOCK_TIMEOUT' }) - expect(r.nextState.kind).toBe('lock_timeout') - }) - - it('creating_or_reusing + WORKTREE_READY → syncing', () => { - const r = transition( - { kind: 'creating_or_reusing', jobId: 'j1', productIds: ['p1'] }, - { type: 'WORKTREE_READY' }, - ) - expect(r.nextState.kind).toBe('syncing') - }) - - it('syncing + SYNC_DONE → ready (no release effect yet)', () => { - const r = transition( - { kind: 'syncing', jobId: 'j1', productIds: ['p1'] }, - { type: 'SYNC_DONE' }, - ) - expect(r.nextState.kind).toBe('ready') - expect(r.effects).toEqual([]) - }) - - it('syncing + SYNC_FAILED → sync_failed + RELEASE_WORKTREE_LOCKS effect', () => { - const r = transition( - { kind: 'syncing', jobId: 'j1', productIds: ['p1'] }, - { type: 'SYNC_FAILED', error: 'boom' }, - ) - expect(r.nextState.kind).toBe('sync_failed') - expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }]) - }) - - it('ready + JOB_TERMINAL → releasing + RELEASE_WORKTREE_LOCKS effect', () => { - const r = transition( - { kind: 'ready', jobId: 'j1', productIds: ['p1'] }, - { type: 'JOB_TERMINAL', jobId: 'j1' }, - ) - expect(r.nextState.kind).toBe('releasing') - expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }]) - }) - - it('ready + STALE_RESET → stale_released + RELEASE_WORKTREE_LOCKS effect', () => { - const r = transition( - { kind: 'ready', jobId: 'j1', productIds: ['p1'] }, - { type: 'STALE_RESET', jobId: 'j1' }, - ) - expect(r.nextState.kind).toBe('stale_released') - expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }]) - }) - - it('forbidden transition (idle + LOCK_ACQUIRED) keeps state, no effects', () => { - const state: WorktreeLeaseState = { kind: 'idle' } - const r = transition(state, { type: 'LOCK_ACQUIRED' }) - expect(r.nextState).toEqual(state) - expect(r.effects).toEqual([]) - }) -}) diff --git a/__tests__/git/file-lock.test.ts b/__tests__/git/file-lock.test.ts deleted file mode 100644 index 981918f..0000000 --- a/__tests__/git/file-lock.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as path from 'node:path' -import { acquireFileLock, acquireFileLocksOrdered } from '../../src/git/file-lock.js' - -describe('file-lock', () => { - let tmpDir: string - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-lock-')) - }) - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('acquires and releases a lock; lockfile is gone after release', async () => { - const lockPath = path.join(tmpDir, 'a.lock') - const release = await acquireFileLock(lockPath) - // proper-lockfile creates a directory at .lock for the actual lock - const stat = await fs.stat(`${lockPath}.lock`).catch(() => null) - expect(stat).not.toBeNull() - await release() - // After release, the .lock dir should be gone - const after = await fs.stat(`${lockPath}.lock`).catch(() => null) - expect(after).toBeNull() - }) - - it('release is idempotent (second call is no-op)', async () => { - const lockPath = path.join(tmpDir, 'b.lock') - const release = await acquireFileLock(lockPath) - await release() - await expect(release()).resolves.toBeUndefined() - }) - - it('second acquire blocks until first release', async () => { - const lockPath = path.join(tmpDir, 'c.lock') - const release1 = await acquireFileLock(lockPath) - - let secondAcquired = false - const second = acquireFileLock(lockPath).then((r) => { - secondAcquired = true - return r - }) - - // Give the second acquire a moment to attempt - await new Promise((r) => setTimeout(r, 200)) - expect(secondAcquired).toBe(false) - - await release1() - const release2 = await second - expect(secondAcquired).toBe(true) - await release2() - }, 10_000) - - it('acquireFileLocksOrdered sorts paths alphabetically (deadlock-free for crossed sets)', async () => { - const a = path.join(tmpDir, 'A.lock') - const b = path.join(tmpDir, 'B.lock') - - // Two concurrent multi-locks with crossed orders both sort to [A, B] - const r1Promise = acquireFileLocksOrdered([b, a]) - - // First should grab both since paths sort the same - const r1 = await r1Promise - - let secondAcquired = false - const r2Promise = acquireFileLocksOrdered([a, b]).then((r) => { - secondAcquired = true - return r - }) - - await new Promise((r) => setTimeout(r, 200)) - expect(secondAcquired).toBe(false) - - await r1() - const r2 = await r2Promise - expect(secondAcquired).toBe(true) - await r2() - }, 15_000) - - it('partial failure releases held locks', async () => { - // Force the second acquire to fail by writing a regular file at the lockfile - // location proper-lockfile wants to create as a directory. - const a = path.join(tmpDir, 'A.lock') - const bPath = path.join(tmpDir, 'B.lock') - // Create a regular file at `${bPath}.lock` so proper-lockfile's mkdir fails with EEXIST - await fs.writeFile(`${bPath}.lock`, 'blocked') - - await expect(acquireFileLocksOrdered([a, bPath])).rejects.toThrow() - - // After failure, A's lock should be released — re-acquire immediately - const r = await acquireFileLock(a) - await r() - }, 90_000) -}) diff --git a/__tests__/git/job-locks.test.ts b/__tests__/git/job-locks.test.ts deleted file mode 100644 index 125620d..0000000 --- a/__tests__/git/job-locks.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as path from 'node:path' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { - registerJobLockReleases, - releaseLocksOnTerminal, - setupProductWorktrees, - _resetJobReleasesForTest, -} from '../../src/git/job-locks.js' - -const exec = promisify(execFile) - -describe('job-locks: registerJobLockReleases + releaseLocksOnTerminal', () => { - beforeEach(() => _resetJobReleasesForTest()) - - it('releaseLocksOnTerminal for unknown job is a no-op', async () => { - await expect(releaseLocksOnTerminal('nonexistent')).resolves.toBeUndefined() - }) - - it('runs registered releases and clears the entry', async () => { - const release = vi.fn().mockResolvedValue(undefined) - registerJobLockReleases('job-1', [release]) - await releaseLocksOnTerminal('job-1') - expect(release).toHaveBeenCalledTimes(1) - // Second call → no-op (cleared) - await releaseLocksOnTerminal('job-1') - expect(release).toHaveBeenCalledTimes(1) - }) - - it('failures in one release do not abort others', async () => { - const r1 = vi.fn().mockRejectedValue(new Error('boom')) - const r2 = vi.fn().mockResolvedValue(undefined) - registerJobLockReleases('job-2', [r1, r2]) - await expect(releaseLocksOnTerminal('job-2')).resolves.toBeUndefined() - expect(r1).toHaveBeenCalled() - expect(r2).toHaveBeenCalled() - }) - - it('append-mode: multiple registers accumulate', async () => { - const r1 = vi.fn().mockResolvedValue(undefined) - const r2 = vi.fn().mockResolvedValue(undefined) - registerJobLockReleases('job-3', [r1]) - registerJobLockReleases('job-3', [r2]) - await releaseLocksOnTerminal('job-3') - expect(r1).toHaveBeenCalledTimes(1) - expect(r2).toHaveBeenCalledTimes(1) - }) -}) - -describe('job-locks: setupProductWorktrees', () => { - let tmpRoot: string - let originalEnv: string | undefined - let bareRepo: string - let originRepo: string - - beforeEach(async () => { - _resetJobReleasesForTest() - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'job-locks-')) - originalEnv = process.env.SCRUM4ME_AGENT_WORKTREE_DIR - process.env.SCRUM4ME_AGENT_WORKTREE_DIR = path.join(tmpRoot, 'agent-worktrees') - - // Set up a bare repo as origin and a clone with origin/main - bareRepo = path.join(tmpRoot, 'origin.git') - await exec('git', ['init', '--bare', '-b', 'main', bareRepo]) - - originRepo = path.join(tmpRoot, 'work') - await exec('git', ['init', '-b', 'main', originRepo]) - await exec('git', ['config', 'user.email', 't@t.local'], { cwd: originRepo }) - await exec('git', ['config', 'user.name', 'Test'], { cwd: originRepo }) - await exec('git', ['remote', 'add', 'origin', bareRepo], { cwd: originRepo }) - await fs.writeFile(path.join(originRepo, 'README.md'), '# init\n') - await exec('git', ['add', '-A'], { cwd: originRepo }) - await exec('git', ['commit', '-m', 'init'], { cwd: originRepo }) - await exec('git', ['push', '-u', 'origin', 'main'], { cwd: originRepo }) - }) - - afterEach(async () => { - if (originalEnv) process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv - else delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR - await fs.rm(tmpRoot, { recursive: true, force: true }) - }) - - it('returns empty when productIds is empty', async () => { - const result = await setupProductWorktrees('j1', [], async () => null) - expect(result).toEqual([]) - }) - - it('creates a product-worktree, registers a lock-release, and releases it', async () => { - const result = await setupProductWorktrees('j2', ['prod-a'], async () => originRepo) - expect(result).toHaveLength(1) - expect(result[0].productId).toBe('prod-a') - expect(result[0].worktreePath).toContain('_products/prod-a') - - // Worktree dir exists with detached HEAD on origin/main - const stat = await fs.stat(result[0].worktreePath) - expect(stat.isDirectory()).toBe(true) - - // Lockfile is held during the job (proper-lockfile creates a .lock dir) - const lockDir = path.join( - process.env.SCRUM4ME_AGENT_WORKTREE_DIR!, - '_products', - 'prod-a.lock.lock', - ) - const lockStat = await fs.stat(lockDir).catch(() => null) - expect(lockStat).not.toBeNull() - - await releaseLocksOnTerminal('j2') - const lockAfter = await fs.stat(lockDir).catch(() => null) - expect(lockAfter).toBeNull() - }) - - it('skips products where resolveRepoRoot returns null', async () => { - const result = await setupProductWorktrees('j3', ['no-repo'], async () => null) - expect(result).toEqual([]) - // Lock was still acquired and registered — release cleans up - await releaseLocksOnTerminal('j3') - }) - - it('output preserves input order regardless of alphabetical lock-acquire order', async () => { - // 'z-primary' sorts AFTER 'a-secondary' alphabetically, but caller passes - // primary first → output[0] must be 'z-primary' so wait_for_job's - // primary_worktree_path = worktrees[0]?.worktreePath points at the right repo. - const result = await setupProductWorktrees( - 'j4', - ['z-primary', 'a-secondary'], - async () => originRepo, - ) - expect(result).toHaveLength(2) - expect(result[0].productId).toBe('z-primary') - expect(result[1].productId).toBe('a-secondary') - await releaseLocksOnTerminal('j4') - }) -}) diff --git a/__tests__/git/pr-enable-auto-merge.test.ts b/__tests__/git/pr-enable-auto-merge.test.ts deleted file mode 100644 index 35de1af..0000000 --- a/__tests__/git/pr-enable-auto-merge.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock node:child_process before importing the module under test -vi.mock('node:child_process', () => ({ - execFile: vi.fn(), -})) - -import { execFile } from 'node:child_process' -import { enableAutoMergeOnPr } from '../../src/git/pr.js' - -const mockExecFile = vi.mocked(execFile) as unknown as ReturnType - -function mockGhFailure(stderr: string) { - mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => { - cb(Object.assign(new Error('gh exit'), { stderr })) - }) as never) -} - -function mockGhSuccess() { - mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => { - cb(null, { stdout: '', stderr: '' }) - }) as never) -} - -describe('enableAutoMergeOnPr — typed errors (PBI-47 C2 layer 1)', () => { - beforeEach(() => { - mockExecFile.mockReset() - }) - - it('returns ok:true on green merge', async () => { - mockGhSuccess() - const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' }) - expect(result.ok).toBe(true) - }) - - it('classifies GH_AUTH_ERROR for 401/403 / permission strings', async () => { - mockGhFailure('gh: HTTP 403: permission denied') - const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' }) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toBe('GH_AUTH_ERROR') - }) - - it('classifies AUTO_MERGE_NOT_ALLOWED for repo-setting refusal', async () => { - mockGhFailure('auto-merge is not allowed for this repository') - const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' }) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toBe('AUTO_MERGE_NOT_ALLOWED') - }) - - it('classifies MERGE_CONFLICT for dirty merge state', async () => { - mockGhFailure('pull request is not in a mergeable state (dirty)') - const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' }) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toBe('MERGE_CONFLICT') - }) - - it('classifies UNKNOWN for unrecognised stderr', async () => { - mockGhFailure('unexpected gh error') - const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' }) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toBe('UNKNOWN') - }) - - it('passes --match-head-commit when expectedHeadSha provided', async () => { - mockGhSuccess() - await enableAutoMergeOnPr({ prUrl: 'pr-url', expectedHeadSha: 'abc123' }) - const callArgs = mockExecFile.mock.calls[0] - expect(callArgs[0]).toBe('gh') - const args = callArgs[1] as string[] - expect(args).toContain('--match-head-commit') - expect(args).toContain('abc123') - expect(args).toContain('--auto') - expect(args).toContain('--squash') - }) -}) diff --git a/__tests__/git/pr.test.ts b/__tests__/git/pr.test.ts index 18a1949..6d8cc72 100644 --- a/__tests__/git/pr.test.ts +++ b/__tests__/git/pr.test.ts @@ -12,7 +12,7 @@ vi.mock('node:util', () => ({ ), })) -import { createPullRequest, markPullRequestReady } from '../../src/git/pr.js' +import { createPullRequest } from '../../src/git/pr.js' beforeEach(() => { vi.clearAllMocks() @@ -66,80 +66,4 @@ describe('createPullRequest', () => { expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') }) }) - - it('passes --draft when draft=true en slaat auto-merge over', async () => { - const calls: string[][] = [] - mockExecFile.mockImplementation( - ( - _cmd: string, - args: string[], - _opts: unknown, - cb: (err: null, res: { stdout: string; stderr: string }) => void, - ) => { - calls.push(args) - cb(null, { - stdout: 'Creating draft pull request...\nhttps://github.com/org/repo/pull/100\n', - stderr: '', - }) - }, - ) - - const result = await createPullRequest({ - worktreePath: '/wt/sprint-1', - branchName: 'feat/sprint-12345678', - title: 'Sprint: Cascade-flow live', - body: 'Sprint draft', - draft: true, - enableAutoMerge: false, - }) - - expect(result).toEqual({ url: 'https://github.com/org/repo/pull/100' }) - expect(calls.some((a) => a.includes('--draft'))).toBe(true) - // gh pr merge --auto mag NIET gestart zijn voor draft + auto-merge=false - expect(calls.some((a) => a[0] === 'pr' && a[1] === 'merge')).toBe(false) - }) -}) - -describe('markPullRequestReady', () => { - it('roept gh pr ready aan met de PR-URL', async () => { - const calls: string[][] = [] - mockExecFile.mockImplementation( - ( - _cmd: string, - args: string[], - _opts: unknown, - cb: (err: null, res: { stdout: string; stderr: string }) => void, - ) => { - calls.push(args) - cb(null, { stdout: '', stderr: '' }) - }, - ) - - const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' }) - - expect(result).toEqual({ ok: true }) - expect(calls[0]).toEqual(['pr', 'ready', 'https://github.com/org/repo/pull/100']) - }) - - it('behandelt "already ready" als success', async () => { - mockExecFile.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => - cb(Object.assign(new Error(''), { stderr: 'Pull request is not in draft state' })), - ) - - const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' }) - - expect(result).toEqual({ ok: true }) - }) - - it('retourneert error op onverwachte gh-fout', async () => { - mockExecFile.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => - cb(new Error('rate limit exceeded')), - ) - - const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' }) - - expect(result).toMatchObject({ error: expect.stringContaining('gh pr ready failed') }) - }) }) diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts index 68cedfd..68f5e19 100644 --- a/__tests__/git/worktree.test.ts +++ b/__tests__/git/worktree.test.ts @@ -113,71 +113,6 @@ describe('createWorktreeForJob', () => { }), ).rejects.toThrow('Worktree path already exists') }) - - it('reuseBranch: reuses an existing local branch', async () => { - const { repoDir, originDir } = await setupRepo() - tmpDirs.push(repoDir, originDir) - await makeWorktreeParent() - - // Sibling already created the branch locally. - await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir) - - const result = await createWorktreeForJob({ - repoRoot: repoDir, - jobId: 'job-reuse-local', - branchName: 'feat/sprint-abc', - baseRef: 'origin/main', - reuseBranch: true, - }) - - const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) - expect(stdout.trim()).toBe('feat/sprint-abc') - expect(result.branchName).toBe('feat/sprint-abc') - }) - - it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => { - const { repoDir, originDir } = await setupRepo() - tmpDirs.push(repoDir, originDir) - await makeWorktreeParent() - - // Branch exists on origin (a sibling pushed it, or the container was - // recreated and the local clone is fresh) but not as a local branch. - await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir) - await git(['push', 'origin', 'feat/sprint-xyz'], repoDir) - await git(['branch', '-D', 'feat/sprint-xyz'], repoDir) - - const result = await createWorktreeForJob({ - repoRoot: repoDir, - jobId: 'job-reuse-origin', - branchName: 'feat/sprint-xyz', - baseRef: 'origin/main', - reuseBranch: true, - }) - - const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) - expect(stdout.trim()).toBe('feat/sprint-xyz') - }) - - it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => { - const { repoDir, originDir } = await setupRepo() - tmpDirs.push(repoDir, originDir) - await makeWorktreeParent() - - // reuseBranch is decided sprint-wide; for the first job targeting THIS - // repo the branch exists neither locally nor on origin. Must not throw - // "invalid reference" — should create it fresh from baseRef. - const result = await createWorktreeForJob({ - repoRoot: repoDir, - jobId: 'job-reuse-fresh', - branchName: 'feat/sprint-newrepo', - baseRef: 'origin/main', - reuseBranch: true, - }) - - const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) - expect(stdout.trim()).toBe('feat/sprint-newrepo') - expect(result.branchName).toBe('feat/sprint-newrepo') - }) }) describe('removeWorktreeForJob', () => { diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts deleted file mode 100644 index 80ea72f..0000000 --- a/__tests__/job-config.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js' - -const KIND_EXPECTED = { - IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'acceptEdits', max_turns: 15 }, - IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'acceptEdits', max_turns: 20 }, - PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'acceptEdits', max_turns: 5 }, - TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 }, - SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null }, -} as const - -describe('getKindDefault', () => { - for (const [kind, expected] of Object.entries(KIND_EXPECTED)) { - it(`returnt de juiste defaults voor ${kind}`, () => { - const cfg = getKindDefault(kind) - expect(cfg.model).toBe(expected.model) - expect(cfg.thinking_budget).toBe(expected.thinking_budget) - expect(cfg.permission_mode).toBe(expected.permission_mode) - expect(cfg.max_turns).toBe(expected.max_turns) - }) - } - - it('valt terug op een veilige fallback voor onbekende kinds', () => { - const cfg = getKindDefault('SOMETHING_NEW') - expect(cfg.model).toBe('claude-sonnet-4-6') - expect(cfg.permission_mode).toBe('default') - }) -}) - -describe('resolveJobConfig — geen overrides', () => { - for (const kind of Object.keys(KIND_EXPECTED)) { - it(`returnt kind-default voor ${kind} zonder overrides`, () => { - const cfg = resolveJobConfig({ kind }, {}) - expect(cfg).toEqual(getKindDefault(kind)) - }) - } -}) - -describe('resolveJobConfig — cascade', () => { - it('product.preferred_model overrult kind-default', () => { - const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' }) - expect(cfg.model).toBe('claude-haiku-4-5-20251001') - }) - - it('job.requested_model overrult product.preferred_model', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' }, - { preferred_model: 'claude-haiku-4-5-20251001' }, - ) - expect(cfg.model).toBe('claude-opus-4-7') - }) - - 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('task.requires_opus overrult ook job.requested_model = haiku', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' }, - {}, - { requires_opus: true }, - ) - expect(cfg.model).toBe('claude-opus-4-7') - }) - - it('job.requested_thinking_budget overrult kind-default', () => { - const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {}) - expect(cfg.thinking_budget).toBe(1024) - }) - - it('product.thinking_budget_default overrult kind-default', () => { - const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 }) - expect(cfg.thinking_budget).toBe(0) - }) - - it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION' }, - { preferred_permission_mode: 'acceptEdits' }, - ) - expect(cfg.permission_mode).toBe('acceptEdits') - }) - - it('max_turns blijft kind-default ook met product- en job-overrides (geen V1-cascade)', () => { - const cfg = resolveJobConfig( - { kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' }, - { preferred_model: 'claude-sonnet-4-6' }, - ) - expect(cfg.max_turns).toBe(15) - }) -}) - -describe('KIND_DEFAULTS.allowed_tools', () => { - 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('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) - }) -}) diff --git a/__tests__/job-heartbeat.test.ts b/__tests__/job-heartbeat.test.ts deleted file mode 100644 index 896f317..0000000 --- a/__tests__/job-heartbeat.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - $queryRaw: vi.fn(), - sprintRun: { findUnique: vi.fn() }, - }, -})) - -vi.mock('../src/auth.js', async (importOriginal) => { - const original = await importOriginal() - return { ...original, requireWriteAccess: vi.fn() } -}) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' - -const mockPrisma = prisma as unknown as { - $queryRaw: ReturnType - sprintRun: { findUnique: ReturnType } -} -const mockAuth = requireWriteAccess as ReturnType - -const TOKEN_ID = 'tok-owner' - -function makeServer() { - let handler: (args: Record) => Promise - const server = { - registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { - handler = fn - }), - call: (args: Record) => handler(args), - } - registerJobHeartbeatTool(server as unknown as McpServer) - return server -} - -beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ - userId: 'u-1', - tokenId: TOKEN_ID, - username: 'agent', - isDemo: false, - }) -}) - -describe('job_heartbeat', () => { - it('returns 403-style error when no row matched (token mismatch / terminal)', async () => { - mockPrisma.$queryRaw.mockResolvedValue([]) - const server = makeServer() - const result = (await server.call({ job_id: 'job-x' })) as { - content: { text: string }[] - isError?: boolean - } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i) - }) - - it('non-SPRINT job returns ok + lease_until without sprint fields', async () => { - const lease = new Date() - mockPrisma.$queryRaw.mockResolvedValue([ - { - id: 'job-1', - lease_until: lease, - kind: 'TASK_IMPLEMENTATION', - sprint_run_id: null, - }, - ]) - const server = makeServer() - const result = (await server.call({ job_id: 'job-1' })) as { - content: { text: string }[] - } - const body = JSON.parse(result.content[0].text) - expect(body).toEqual({ - ok: true, - job_id: 'job-1', - lease_until: lease.toISOString(), - sprint_run_status: null, - sprint_run_pause_reason: null, - }) - expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled() - }) - - it('SPRINT job returns sprint_run_status from sibling lookup', async () => { - const lease = new Date() - mockPrisma.$queryRaw.mockResolvedValue([ - { - id: 'job-2', - lease_until: lease, - kind: 'SPRINT_IMPLEMENTATION', - sprint_run_id: 'sr-1', - }, - ]) - mockPrisma.sprintRun.findUnique.mockResolvedValue({ - status: 'PAUSED', - pause_context: { pause_reason: 'QUOTA_DEPLETED' }, - }) - - const server = makeServer() - const result = (await server.call({ job_id: 'job-2' })) as { - content: { text: string }[] - } - const body = JSON.parse(result.content[0].text) - expect(body).toMatchObject({ - ok: true, - sprint_run_status: 'PAUSED', - sprint_run_pause_reason: 'QUOTA_DEPLETED', - }) - }) - - it('SPRINT job tolerates missing pause_context', async () => { - const lease = new Date() - mockPrisma.$queryRaw.mockResolvedValue([ - { - id: 'job-3', - lease_until: lease, - kind: 'SPRINT_IMPLEMENTATION', - sprint_run_id: 'sr-2', - }, - ]) - mockPrisma.sprintRun.findUnique.mockResolvedValue({ - status: 'RUNNING', - pause_context: null, - }) - - const server = makeServer() - const result = (await server.call({ job_id: 'job-3' })) as { - content: { text: string }[] - } - const body = JSON.parse(result.content[0].text) - expect(body.sprint_run_status).toBe('RUNNING') - expect(body.sprint_run_pause_reason).toBeNull() - }) -}) diff --git a/__tests__/kind-prompts.test.ts b/__tests__/kind-prompts.test.ts deleted file mode 100644 index fda08f4..0000000 --- a/__tests__/kind-prompts.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { ClaudeJobKind } from '@prisma/client' -import { getKindPromptText, getIdeaPromptText } from '../src/lib/kind-prompts.js' - -const KINDS: ClaudeJobKind[] = [ - 'IDEA_GRILL', - 'IDEA_MAKE_PLAN', - 'TASK_IMPLEMENTATION', - 'SPRINT_IMPLEMENTATION', - 'PLAN_CHAT', -] - -describe('getKindPromptText', () => { - it.each(KINDS)('returnt non-empty content voor %s', (kind) => { - const text = getKindPromptText(kind) - expect(text.length).toBeGreaterThan(0) - }) - - it('TASK_IMPLEMENTATION-prompt verbiedt wait_for_job', () => { - const text = getKindPromptText('TASK_IMPLEMENTATION') - expect(text).toMatch(/GEEN.*wait_for_job/) - }) - - it('SPRINT_IMPLEMENTATION-prompt verbiedt job_heartbeat', () => { - const text = getKindPromptText('SPRINT_IMPLEMENTATION') - expect(text).toMatch(/GEEN.*job_heartbeat/) - }) - - it.each(KINDS)( - '%s-prompt noemt $PAYLOAD_PATH als variabele (alle kinds — runner doet substitution)', - (kind) => { - const text = getKindPromptText(kind) - expect(text).toContain('$PAYLOAD_PATH') - }, - ) - - it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)( - '%s-prompt verwijst niet meer naar wait_for_job (refactor: runner claimt)', - (kind) => { - const text = getKindPromptText(kind) - expect(text).not.toContain('wait_for_job') - }, - ) - - it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)( - '%s-prompt bevat geen onvervangen {idea_*} placeholders', - (kind) => { - const text = getKindPromptText(kind) - expect(text).not.toMatch(/\{idea_code\}|\{idea_title\}/) - }, - ) -}) - -describe('getIdeaPromptText (back-compat)', () => { - it('returnt content voor IDEA_GRILL', () => { - expect(getIdeaPromptText('IDEA_GRILL').length).toBeGreaterThan(0) - }) - it('returnt content voor IDEA_MAKE_PLAN', () => { - expect(getIdeaPromptText('IDEA_MAKE_PLAN').length).toBeGreaterThan(0) - }) - it('returnt empty string voor non-idea kind', () => { - expect(getIdeaPromptText('TASK_IMPLEMENTATION')).toBe('') - }) -}) diff --git a/__tests__/update-idea-plan-reviewed.test.ts b/__tests__/update-idea-plan-reviewed.test.ts deleted file mode 100644 index 257fce4..0000000 --- a/__tests__/update-idea-plan-reviewed.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - idea: { update: vi.fn() }, - ideaLog: { create: vi.fn() }, - $transaction: vi.fn(), - }, -})) - -vi.mock('../src/auth.js', () => ({ - requireWriteAccess: vi.fn(), - PermissionDeniedError: class PermissionDeniedError extends Error { - constructor(message = 'Demo accounts cannot perform write operations') { - super(message) - this.name = 'PermissionDeniedError' - } - }, -})) - -vi.mock('../src/access.js', () => ({ - userOwnsIdea: vi.fn(), -})) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { userOwnsIdea } from '../src/access.js' -import { handleUpdateIdeaPlanReviewed } from '../src/tools/update-idea-plan-reviewed.js' - -const mockPrisma = prisma as unknown as { - idea: { update: ReturnType } - ideaLog: { create: ReturnType } - $transaction: ReturnType -} -const mockRequireWriteAccess = requireWriteAccess as ReturnType -const mockUserOwnsIdea = userOwnsIdea as ReturnType - -const IDEA_ID = 'idea-1' -const USER_ID = 'user-1' -const REVIEW_LOG = { - rounds: [{ score: 88 }], - convergence: { stable_at_round: 2 }, - approval: { status: 'approved' }, -} - -beforeEach(() => { - vi.clearAllMocks() - mockRequireWriteAccess.mockResolvedValue({ - userId: USER_ID, - tokenId: 'tok-1', - username: 'alice', - isDemo: false, - }) - mockUserOwnsIdea.mockResolvedValue(true) - // $transaction returns the array of its two operations' results; the handler - // only reads result[0] (the idea.update result). - mockPrisma.$transaction.mockImplementation(async () => [ - { id: IDEA_ID, status: 'PLACEHOLDER', code: 'IDEA-1' }, - {}, - ]) -}) - -function parseResult(result: Awaited>) { - const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' - try { - return JSON.parse(text) - } catch { - return text - } -} - -// The handler builds `data.status` inside the idea.update call passed to -// $transaction. We capture it by inspecting the prisma.idea.update mock args. -function statusPassedToUpdate(): string | undefined { - const call = mockPrisma.idea.update.mock.calls[0] - return call?.[0]?.data?.status -} - -describe('handleUpdateIdeaPlanReviewed — status transition', () => { - it('approval_status="approved" → PLAN_REVIEWED', async () => { - await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - approval_status: 'approved', - }) - expect(statusPassedToUpdate()).toBe('PLAN_REVIEWED') - }) - - it('approval_status="rejected" → PLAN_REVIEW_FAILED', async () => { - await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - approval_status: 'rejected', - }) - expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') - }) - - it('approval_status="pending" → PLAN_REVIEW_FAILED (needs manual approval, never silently approved)', async () => { - await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - approval_status: 'pending', - }) - expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') - }) - - it('omitted approval_status → PLAN_REVIEW_FAILED (safe default, not PLAN_REVIEWED)', async () => { - await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - }) - expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') - }) - - it('returns "Idea not found" when the user does not own the idea', async () => { - mockUserOwnsIdea.mockResolvedValue(false) - const result = await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - approval_status: 'approved', - }) - expect(parseResult(result)).toContain('Idea not found') - expect(mockPrisma.idea.update).not.toHaveBeenCalled() - }) - - it('persists review_log + reviewed_at and logs a PLAN_REVIEW_RESULT entry', async () => { - await handleUpdateIdeaPlanReviewed({ - idea_id: IDEA_ID, - review_log: REVIEW_LOG, - approval_status: 'approved', - }) - const updateArg = mockPrisma.idea.update.mock.calls[0]?.[0] - expect(updateArg?.data?.plan_review_log).toEqual(REVIEW_LOG) - expect(updateArg?.data?.reviewed_at).toBeInstanceOf(Date) - - const logArg = mockPrisma.ideaLog.create.mock.calls[0]?.[0] - expect(logArg?.data?.type).toBe('PLAN_REVIEW_RESULT') - expect(logArg?.data?.idea_id).toBe(IDEA_ID) - }) -}) diff --git a/__tests__/update-job-status-auto-pr.test.ts b/__tests__/update-job-status-auto-pr.test.ts index e92fdb3..4a901ad 100644 --- a/__tests__/update-job-status-auto-pr.test.ts +++ b/__tests__/update-job-status-auto-pr.test.ts @@ -4,13 +4,12 @@ vi.mock('../src/prisma.js', () => ({ prisma: { product: { findUnique: vi.fn() }, task: { findUnique: vi.fn() }, - claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() }, + claudeJob: { findFirst: vi.fn() }, }, })) vi.mock('../src/git/pr.js', () => ({ createPullRequest: vi.fn(), - markPullRequestReady: vi.fn(), })) import { prisma } from '../src/prisma.js' @@ -20,11 +19,7 @@ import { maybeCreateAutoPr } from '../src/tools/update-job-status.js' const mockPrisma = prisma as unknown as { product: { findUnique: ReturnType } task: { findUnique: ReturnType } - claudeJob: { - findFirst: ReturnType - findMany: ReturnType - findUnique: ReturnType - } + claudeJob: { findFirst: ReturnType } } const mockCreatePr = createPullRequest as ReturnType @@ -42,12 +37,9 @@ beforeEach(() => { mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true }) mockPrisma.task.findUnique.mockResolvedValue({ title: 'Add feature', - repo_url: null, story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' }, }) - mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default - // Default: legacy job zonder sprint_run (STORY-mode pad). - mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null }) + mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' }) }) @@ -64,27 +56,12 @@ describe('maybeCreateAutoPr', () => { }) it('reuses sibling pr_url when another job in same story already opened a PR', async () => { - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } }, - ]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' }) const url = await maybeCreateAutoPr(BASE_OPTS) expect(url).toBe('https://github.com/org/repo/pull/77') expect(mockCreatePr).not.toHaveBeenCalled() }) - it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => { - // Sibling targeted another repo via task.repo_url — its PR must not leak in. - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { - pr_url: 'https://github.com/org/other-repo/pull/12', - task: { repo_url: 'https://github.com/org/other-repo' }, - }, - ]) - const url = await maybeCreateAutoPr(BASE_OPTS) - expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's - expect(mockCreatePr).toHaveBeenCalledOnce() - }) - it('returns null when auto_pr=false', async () => { mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false }) const url = await maybeCreateAutoPr(BASE_OPTS) @@ -95,7 +72,6 @@ describe('maybeCreateAutoPr', () => { it('uses story title without code prefix when story has no code', async () => { mockPrisma.task.findUnique.mockResolvedValue({ title: 'Add feature', - repo_url: null, story: { id: 'story-1', code: null, title: 'Story title' }, }) await maybeCreateAutoPr(BASE_OPTS) @@ -104,66 +80,6 @@ describe('maybeCreateAutoPr', () => { ) }) - it('SPRINT-mode: maakt een draft-PR aan met sprint-titel, geen auto-merge', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-1', - sprint_run: { - id: 'run-1', - pr_strategy: 'SPRINT', - sprint: { sprint_goal: 'Cascade-flow live' }, - }, - }) - - const url = await maybeCreateAutoPr(BASE_OPTS) - - expect(url).toBe('https://github.com/org/repo/pull/99') - expect(mockCreatePr).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Sprint: Cascade-flow live', - draft: true, - enableAutoMerge: false, - }), - ) - }) - - it('SPRINT-mode: hergebruikt sibling-PR binnen dezelfde SprintRun', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-1', - sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } }, - }) - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } }, - ]) - - const url = await maybeCreateAutoPr(BASE_OPTS) - - expect(url).toBe('https://github.com/org/repo/pull/55') - expect(mockCreatePr).not.toHaveBeenCalled() - }) - - it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-1', - sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } }, - }) - // Deze job target een ander repo via task.repo_url. - mockPrisma.task.findUnique.mockResolvedValue({ - title: 'MCP-taak', - repo_url: 'https://github.com/org/scrum4me-mcp', - story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' }, - }) - // Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket. - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } }, - ]) - - const url = await maybeCreateAutoPr(BASE_OPTS) - - // Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo. - expect(url).toBe('https://github.com/org/repo/pull/99') - expect(mockCreatePr).toHaveBeenCalledOnce() - }) - it('returns null and does not throw when gh fails', async () => { mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' }) const url = await maybeCreateAutoPr(BASE_OPTS) diff --git a/__tests__/update-job-status-push.test.ts b/__tests__/update-job-status-push.test.ts index 3ffd6b3..1232670 100644 --- a/__tests__/update-job-status-push.test.ts +++ b/__tests__/update-job-status-push.test.ts @@ -5,26 +5,13 @@ vi.mock('../src/git/push.js', () => ({ pushBranchForJob: vi.fn(), })) -vi.mock('../src/prisma.js', () => ({ - prisma: { - claudeJob: { - findUnique: vi.fn(), - }, - }, -})) - import { pushBranchForJob } from '../src/git/push.js' -import { prisma } from '../src/prisma.js' import { prepareDoneUpdate } from '../src/tools/update-job-status.js' const mockPush = pushBranchForJob as ReturnType -const mockFindUnique = (prisma as unknown as { - claudeJob: { findUnique: ReturnType } -}).claudeJob.findUnique beforeEach(() => { vi.clearAllMocks() - mockFindUnique.mockResolvedValue(null) }) describe('prepareDoneUpdate', () => { @@ -52,25 +39,8 @@ describe('prepareDoneUpdate', () => { }) }) - it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => { + it('derives branchName from jobId when branch is undefined', async () => { process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' - mockFindUnique.mockResolvedValue({ branch: 'feat/sprint-fvy30lvv' }) - mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/sprint-fvy30lvv' }) - - await prepareDoneUpdate('job-abc12345', undefined) - - expect(mockFindUnique).toHaveBeenCalledWith({ - where: { id: 'job-abc12345' }, - select: { branch: true }, - }) - expect(mockPush).toHaveBeenCalledWith( - expect.objectContaining({ branchName: 'feat/sprint-fvy30lvv' }), - ) - }) - - it('falls back to feat/job-<8> when neither branch arg nor DB.branch is set', async () => { - process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' - mockFindUnique.mockResolvedValue({ branch: null }) mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' }) await prepareDoneUpdate('job-abc12345', undefined) diff --git a/__tests__/update-job-status-skipped.test.ts b/__tests__/update-job-status-skipped.test.ts deleted file mode 100644 index 53e745c..0000000 --- a/__tests__/update-job-status-skipped.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Unit-tests voor de no-op SKIPPED exit-route in update_job_status (PBI-57 ST-1273). -// Volle handler-integratie wordt niet hier getest — die hangt aan tientallen -// MCP/Prisma-mocks. Wel testen we de geëxporteerde helpers die expliciet -// SKIPPED-aware zijn gemaakt: resolveNextAction en cleanupWorktreeForTerminalStatus. - -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - claudeJob: { findUnique: vi.fn(), count: vi.fn() }, - }, -})) - -vi.mock('../src/git/worktree.js', () => ({ - removeWorktreeForJob: vi.fn(), -})) - -vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - resolveRepoRoot: vi.fn(), - } -}) - -import { prisma } from '../src/prisma.js' -import { removeWorktreeForJob } from '../src/git/worktree.js' -import { resolveRepoRoot } from '../src/tools/wait-for-job.js' -import { - cleanupWorktreeForTerminalStatus, - resolveNextAction, -} from '../src/tools/update-job-status.js' - -const mockRemove = removeWorktreeForJob as ReturnType -const mockResolve = resolveRepoRoot as ReturnType -const mockPrisma = prisma as unknown as { - claudeJob: { - findUnique: ReturnType - count: ReturnType - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-default' } }) - mockPrisma.claudeJob.count.mockResolvedValue(0) -}) - -describe('resolveNextAction — skipped pad', () => { - it('returns wait_for_job_again when queue has jobs after skipped', () => { - expect(resolveNextAction(2, 'skipped')).toBe('wait_for_job_again') - }) - - it('returns queue_empty when queue is empty after skipped', () => { - expect(resolveNextAction(0, 'skipped')).toBe('queue_empty') - }) -}) - -describe('cleanupWorktreeForTerminalStatus — skipped pad', () => { - it('calls removeWorktreeForJob with keepBranch=false when skipped (no push happened)', async () => { - mockResolve.mockResolvedValue('/repos/my-project') - mockRemove.mockResolvedValue({ removed: true }) - - await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined) - - expect(mockRemove).toHaveBeenCalledWith({ - repoRoot: '/repos/my-project', - jobId: 'job-skip', - keepBranch: false, - }) - }) - - it('keeps keepBranch=false when skipped even if a branch is reported', async () => { - mockResolve.mockResolvedValue('/repos/my-project') - mockRemove.mockResolvedValue({ removed: true }) - - await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', 'feat/job-skip') - - expect(mockRemove).toHaveBeenCalledWith({ - repoRoot: '/repos/my-project', - jobId: 'job-skip', - keepBranch: false, - }) - }) - - it('defers cleanup when sibling jobs in same story are still active (skipped path)', async () => { - mockResolve.mockResolvedValue('/repos/my-project') - mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-shared' } }) - mockPrisma.claudeJob.count.mockResolvedValue(1) - - await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined) - - expect(mockRemove).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/update-job-status-sprint-gate.test.ts b/__tests__/update-job-status-sprint-gate.test.ts deleted file mode 100644 index e96b94a..0000000 --- a/__tests__/update-job-status-sprint-gate.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - sprintTaskExecution: { - findMany: vi.fn(), - }, - sprintRun: { - findUnique: vi.fn(), - update: vi.fn(), - }, - story: { - count: vi.fn(), - }, - }, -})) - -import { prisma } from '../src/prisma.js' -import { - checkSprintVerifyGate, - finalizeSprintRunOnDone, -} from '../src/tools/update-job-status.js' - -type MockedPrisma = { - sprintTaskExecution: { findMany: ReturnType } - sprintRun: { - findUnique: ReturnType - update: ReturnType - } - story: { count: ReturnType } -} - -const mocked = prisma as unknown as MockedPrisma - -const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.' - -function execRow(overrides: Record) { - return { - id: 'exec-' + Math.random().toString(36).slice(2, 8), - task_id: 't1', - order: 0, - status: 'DONE', - verify_result: 'ALIGNED', - verify_summary: null, - verify_required_snapshot: 'ALIGNED_OR_PARTIAL', - verify_only_snapshot: false, - task: { code: 'TASK-1', title: 'Sample task' }, - ...overrides, - } -} - -describe('checkSprintVerifyGate', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('rejects when no executions exist (claim-bug)', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i) - }) - - it('blocks PENDING/RUNNING executions', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ status: 'PENDING' }), - execRow({ status: 'RUNNING' }), - ]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) { - expect(r.error).toMatch(/PENDING/) - expect(r.error).toMatch(/RUNNING/) - } - }) - - it('blocks FAILED executions', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ status: 'FAILED' }), - ]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) expect(r.error).toMatch(/FAILED/) - }) - - it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }), - ]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) expect(r.error).toMatch(/SKIPPED/) - }) - - it('allows SKIPPED when verify_required_snapshot=ANY', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }), - ]) - expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) - }) - - it('runs per-row gate for DONE executions', async () => { - // PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ - status: 'DONE', - verify_result: 'PARTIAL', - verify_summary: null, - verify_required_snapshot: 'ALIGNED_OR_PARTIAL', - }), - ]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) expect(r.error).toMatch(/DONE-gate/) - }) - - it('passes when all DONE rows pass per-row gate', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ verify_result: 'ALIGNED' }), - execRow({ - verify_result: 'PARTIAL', - verify_summary: LONG_SUMMARY, - verify_required_snapshot: 'ALIGNED_OR_PARTIAL', - }), - ]) - expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) - }) - - it('aggregates multiple blockers in one error message', async () => { - mocked.sprintTaskExecution.findMany.mockResolvedValue([ - execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }), - execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }), - ]) - const r = await checkSprintVerifyGate('job-x') - expect(r.allowed).toBe(false) - if (!r.allowed) { - expect(r.error).toMatch(/2 task\(s\) blokkeren/) - expect(r.error).toMatch(/A: a/) - expect(r.error).toMatch(/B: b/) - } - }) -}) - -describe('finalizeSprintRunOnDone', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('no-op when SprintRun already DONE (idempotent)', async () => { - mocked.sprintRun.findUnique.mockResolvedValue({ - id: 'sr-1', - status: 'DONE', - sprint_id: 's1', - }) - await finalizeSprintRunOnDone('sr-1') - expect(mocked.sprintRun.update).not.toHaveBeenCalled() - }) - - it('no-op when SprintRun does not exist', async () => { - mocked.sprintRun.findUnique.mockResolvedValue(null) - await finalizeSprintRunOnDone('sr-x') - expect(mocked.sprintRun.update).not.toHaveBeenCalled() - }) - - it('no-op when stories still open', async () => { - mocked.sprintRun.findUnique.mockResolvedValue({ - id: 'sr-1', - status: 'RUNNING', - sprint_id: 's1', - }) - mocked.story.count.mockResolvedValue(2) - await finalizeSprintRunOnDone('sr-1') - expect(mocked.sprintRun.update).not.toHaveBeenCalled() - }) - - it('sets SprintRun → DONE when all stories DONE/FAILED', async () => { - mocked.sprintRun.findUnique.mockResolvedValue({ - id: 'sr-1', - status: 'RUNNING', - sprint_id: 's1', - }) - mocked.story.count.mockResolvedValue(0) - await finalizeSprintRunOnDone('sr-1') - expect(mocked.sprintRun.update).toHaveBeenCalledWith({ - where: { id: 'sr-1' }, - data: expect.objectContaining({ - status: 'DONE', - finished_at: expect.any(Date), - }), - }) - }) -}) diff --git a/__tests__/update-job-status-timestamps.test.ts b/__tests__/update-job-status-timestamps.test.ts deleted file mode 100644 index d4ab80f..0000000 --- a/__tests__/update-job-status-timestamps.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Unit-tests voor resolveJobTimestamps — de status-gedreven timestamp-helper -// van update_job_status. Pure functie, geen mocks (zoals update-job-status-gate). - -import { describe, it, expect } from 'vitest' -import { resolveJobTimestamps } from '../src/tools/update-job-status.js' - -const NOW = new Date('2026-05-14T12:00:00.000Z') -const EARLIER = new Date('2026-05-14T11:00:00.000Z') - -describe('resolveJobTimestamps', () => { - describe('running', () => { - it('sets started_at when not yet set, no finished_at', () => { - const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }, NOW) - expect(r.started_at).toBe(NOW) - expect(r.finished_at).toBeUndefined() - expect(r.claimed_at).toBeUndefined() - }) - - it('is set-once: does not re-stamp started_at when already set', () => { - const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: EARLIER }, NOW) - expect(r.started_at).toBeUndefined() - expect(r.finished_at).toBeUndefined() - expect(r.claimed_at).toBeUndefined() - }) - }) - - describe('terminal transitions (done/failed/skipped)', () => { - it.each(['done', 'failed', 'skipped'] as const)( - 'backfills started_at and sets finished_at for %s when started_at is null', - (status) => { - const r = resolveJobTimestamps(status, { claimed_at: EARLIER, started_at: null }, NOW) - expect(r.started_at).toBe(NOW) - expect(r.finished_at).toBe(NOW) - expect(r.claimed_at).toBeUndefined() - }, - ) - - it('only sets finished_at when started_at is already set', () => { - const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW) - expect(r.started_at).toBeUndefined() - expect(r.finished_at).toBe(NOW) - expect(r.claimed_at).toBeUndefined() - }) - }) - - describe('claimed_at backfill', () => { - it.each(['running', 'done', 'failed', 'skipped'] as const)( - 'backfills claimed_at for %s when it is null', - (status) => { - const r = resolveJobTimestamps(status, { claimed_at: null, started_at: null }, NOW) - expect(r.claimed_at).toBe(NOW) - }, - ) - - it('never returns claimed_at when it is already set', () => { - const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW) - expect(r.claimed_at).toBeUndefined() - }) - }) - - it('returns only finished_at when all timestamps are already set and status is terminal', () => { - const r = resolveJobTimestamps('failed', { claimed_at: EARLIER, started_at: EARLIER }, NOW) - expect(r).toEqual({ finished_at: NOW }) - }) - - it('defaults now to a fresh Date when omitted', () => { - const before = Date.now() - const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }) - const after = Date.now() - expect(r.started_at).toBeInstanceOf(Date) - expect(r.started_at!.getTime()).toBeGreaterThanOrEqual(before) - expect(r.started_at!.getTime()).toBeLessThanOrEqual(after) - }) -}) diff --git a/__tests__/update-sprint.test.ts b/__tests__/update-sprint.test.ts deleted file mode 100644 index 3c62790..0000000 --- a/__tests__/update-sprint.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - sprint: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})) - -vi.mock('../src/auth.js', () => ({ - requireWriteAccess: vi.fn(), - PermissionDeniedError: class PermissionDeniedError extends Error { - constructor(message = 'Demo accounts cannot perform write operations') { - super(message) - this.name = 'PermissionDeniedError' - } - }, -})) - -vi.mock('../src/access.js', () => ({ - userCanAccessProduct: vi.fn(), -})) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { userCanAccessProduct } from '../src/access.js' -import { handleUpdateSprint } from '../src/tools/update-sprint.js' - -const mockPrisma = prisma as unknown as { - sprint: { - findUnique: ReturnType - update: ReturnType - } -} -const mockRequireWriteAccess = requireWriteAccess as ReturnType -const mockUserCanAccessProduct = userCanAccessProduct as ReturnType - -const SPRINT_ID = 'spr-1' -const PRODUCT_ID = 'prod-1' -const USER_ID = 'user-1' - -beforeEach(() => { - vi.clearAllMocks() - mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) - mockUserCanAccessProduct.mockResolvedValue(true) - mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID }) - mockPrisma.sprint.update.mockResolvedValue({ - id: SPRINT_ID, - code: 'S-2026-05-11-1', - sprint_goal: 'g', - status: 'OPEN', - start_date: new Date('2026-05-11'), - end_date: null, - completed_at: null, - }) -}) - -function getText(result: Awaited>) { - return result.content?.[0]?.type === 'text' ? result.content[0].text : '' -} - -describe('handleUpdateSprint', () => { - it('returns error when no fields provided', async () => { - const result = await handleUpdateSprint({ sprint_id: SPRINT_ID }) - - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - expect(getText(result)).toMatch(/Minstens één veld vereist/) - }) - - it('updates status only', async () => { - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) - - expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.where).toEqual({ id: SPRINT_ID }) - expect(args.data).toEqual({ status: 'OPEN' }) - }) - - it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => { - const before = Date.now() - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) - const after = Date.now() - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.status).toBe('CLOSED') - expect(args.data.end_date).toBeInstanceOf(Date) - expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before) - expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after) - expect(args.data.completed_at).toBeInstanceOf(Date) - expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before) - expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after) - }) - - it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => { - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' }) - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.end_date).toBeInstanceOf(Date) - expect(args.data.completed_at).toBeUndefined() - }) - - it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => { - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' }) - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.end_date).toBeInstanceOf(Date) - expect(args.data.completed_at).toBeUndefined() - }) - - it('still sets completed_at when status → CLOSED even with explicit end_date', async () => { - await handleUpdateSprint({ - sprint_id: SPRINT_ID, - status: 'CLOSED', - end_date: '2025-12-31', - }) - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31') - expect(args.data.completed_at).toBeInstanceOf(Date) - }) - - it('does NOT auto-set end_date or completed_at when status → OPEN', async () => { - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.end_date).toBeUndefined() - expect(args.data.completed_at).toBeUndefined() - }) - - it('updates multiple fields at once', async () => { - await handleUpdateSprint({ - sprint_id: SPRINT_ID, - sprint_goal: 'New goal', - start_date: '2026-05-15', - }) - - const args = mockPrisma.sprint.update.mock.calls[0][0] - expect(args.data.sprint_goal).toBe('New goal') - expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15') - expect(args.data.status).toBeUndefined() - expect(args.data.end_date).toBeUndefined() - }) - - it('returns error when sprint not found', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(null) - - const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) - - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - expect(getText(result)).toMatch(/not found/) - }) - - it('returns error when user cannot access sprint product', async () => { - mockUserCanAccessProduct.mockResolvedValue(false) - - const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) - - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - expect(getText(result)).toMatch(/not accessible/) - }) - - it('allows any status transition (no state-machine)', async () => { - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) - expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) - - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) - expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2) - - await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) - expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3) - }) -}) diff --git a/__tests__/update-task-execution.test.ts b/__tests__/update-task-execution.test.ts deleted file mode 100644 index a893650..0000000 --- a/__tests__/update-task-execution.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - sprintTaskExecution: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})) - -vi.mock('../src/auth.js', async (importOriginal) => { - const original = await importOriginal() - return { ...original, requireWriteAccess: vi.fn() } -}) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' - -const mockPrisma = prisma as unknown as { - sprintTaskExecution: { - findUnique: ReturnType - update: ReturnType - } -} -const mockAuth = requireWriteAccess as ReturnType - -const TOKEN_ID = 'tok-owner' - -function makeServer() { - let handler: (args: Record) => Promise - const server = { - registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { - handler = fn - }), - call: (args: Record) => handler(args), - } - registerUpdateTaskExecutionTool(server as unknown as McpServer) - return server -} - -function execRecord(overrides: Record = {}) { - return { - id: 'exec-1', - sprint_job_id: 'job-1', - sprint_job: { - claimed_by_token_id: TOKEN_ID, - status: 'CLAIMED', - kind: 'SPRINT_IMPLEMENTATION', - }, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ - userId: 'u-1', - tokenId: TOKEN_ID, - username: 'agent', - isDemo: false, - }) -}) - -describe('update_task_execution', () => { - it('rejects when execution not found', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) - const server = makeServer() - const result = (await server.call({ - execution_id: 'missing', - status: 'RUNNING', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/not found/i) - }) - - it('rejects wrong job-kind', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ - sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' }, - }), - ) - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - status: 'RUNNING', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/) - }) - - it('rejects when token does not own the job', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ - sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, - }), - ) - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - status: 'RUNNING', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/Forbidden/) - }) - - it('rejects when job is in terminal state', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ - sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' }, - }), - ) - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - status: 'DONE', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/terminal/) - }) - - it('writes started_at on RUNNING', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) - mockPrisma.sprintTaskExecution.update.mockResolvedValue({ - id: 'exec-1', - status: 'RUNNING', - base_sha: null, - head_sha: null, - verify_result: null, - verify_summary: null, - skip_reason: null, - started_at: new Date(), - finished_at: null, - }) - - const server = makeServer() - await server.call({ execution_id: 'exec-1', status: 'RUNNING' }) - - const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] - expect(updateCall.data.status).toBe('RUNNING') - expect(updateCall.data.started_at).toBeInstanceOf(Date) - expect(updateCall.data.finished_at).toBeUndefined() - }) - - it('writes finished_at on DONE/FAILED/SKIPPED', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) - mockPrisma.sprintTaskExecution.update.mockResolvedValue({ - id: 'exec-1', - status: 'DONE', - base_sha: 'sha-base', - head_sha: 'sha-head', - verify_result: null, - verify_summary: null, - skip_reason: null, - started_at: new Date(), - finished_at: new Date(), - }) - - const server = makeServer() - await server.call({ - execution_id: 'exec-1', - status: 'DONE', - head_sha: 'sha-head', - }) - - const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] - expect(updateCall.data.status).toBe('DONE') - expect(updateCall.data.finished_at).toBeInstanceOf(Date) - expect(updateCall.data.head_sha).toBe('sha-head') - }) - - it('persists skip_reason on SKIPPED', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) - mockPrisma.sprintTaskExecution.update.mockResolvedValue({ - id: 'exec-1', - status: 'SKIPPED', - base_sha: null, - head_sha: null, - verify_result: null, - verify_summary: null, - skip_reason: 'no-op task', - started_at: null, - finished_at: new Date(), - }) - - const server = makeServer() - await server.call({ - execution_id: 'exec-1', - status: 'SKIPPED', - skip_reason: 'no-op task', - }) - - const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] - expect(updateCall.data.skip_reason).toBe('no-op task') - expect(updateCall.data.finished_at).toBeInstanceOf(Date) - }) -}) diff --git a/__tests__/verify-sprint-task.test.ts b/__tests__/verify-sprint-task.test.ts deleted file mode 100644 index 77bbc1b..0000000 --- a/__tests__/verify-sprint-task.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - sprintTaskExecution: { - findUnique: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - }, - }, -})) - -vi.mock('../src/auth.js', async (importOriginal) => { - const original = await importOriginal() - return { ...original, requireWriteAccess: vi.fn() } -}) - -vi.mock('../src/verify/classify.js', () => ({ - classifyDiffAgainstPlan: vi.fn(), -})) - -vi.mock('node:child_process', () => ({ - execFile: vi.fn(), -})) - -import { prisma } from '../src/prisma.js' -import { requireWriteAccess } from '../src/auth.js' -import { classifyDiffAgainstPlan } from '../src/verify/classify.js' -import { execFile } from 'node:child_process' -import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' - -const mockPrisma = prisma as unknown as { - sprintTaskExecution: { - findUnique: ReturnType - findFirst: ReturnType - update: ReturnType - } -} -const mockAuth = requireWriteAccess as ReturnType -const mockClassify = classifyDiffAgainstPlan as ReturnType -const mockExecFile = execFile as unknown as ReturnType - -const TOKEN_ID = 'tok-owner' - -function makeServer() { - let handler: (args: Record) => Promise - const server = { - registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { - handler = fn - }), - call: (args: Record) => handler(args), - } - registerVerifySprintTaskTool(server as unknown as McpServer) - return server -} - -function stubGitDiff(stdout: string) { - // promisify(execFile) calls (cmd, args, opts, cb) - mockExecFile.mockImplementation( - ( - _cmd: string, - _args: string[], - _opts: unknown, - cb: (err: null, result: { stdout: string; stderr: string }) => void, - ) => { - cb(null, { stdout, stderr: '' }) - }, - ) -} - -function execRecord(overrides: Record = {}) { - return { - id: 'exec-1', - sprint_job_id: 'job-1', - order: 0, - base_sha: 'sha-base', - plan_snapshot: 'frozen plan', - verify_required_snapshot: 'ALIGNED_OR_PARTIAL', - verify_only_snapshot: false, - sprint_job: { - claimed_by_token_id: TOKEN_ID, - status: 'CLAIMED', - kind: 'SPRINT_IMPLEMENTATION', - }, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ - userId: 'u-1', - tokenId: TOKEN_ID, - username: 'agent', - isDemo: false, - }) -}) - -describe('verify_sprint_task', () => { - it('rejects when execution not found', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) - const server = makeServer() - const result = (await server.call({ - execution_id: 'missing', - worktree_path: '/tmp/wt', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/not found/i) - }) - - it('rejects wrong token', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ - sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, - }), - ) - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/Forbidden/) - }) - - it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) - stubGitDiff('diff --git a/x b/x\n+ change\n') - mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' }) - - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - summary: 'Refactor touched extra files for type narrowing.', - })) as { content: { text: string }[] } - const body = JSON.parse(result.content[0].text) - expect(body.result).toBe('partial') - expect(body.allowed_for_done).toBe(true) - expect(body.reason).toBeNull() - }) - - it('PARTIAL without summary returns allowed_for_done=false', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) - stubGitDiff('diff --git a/x b/x\n') - mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' }) - - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - })) as { content: { text: string }[] } - const body = JSON.parse(result.content[0].text) - expect(body.result).toBe('partial') - expect(body.allowed_for_done).toBe(false) - expect(body.reason).toMatch(/summary/i) - }) - - it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ verify_required_snapshot: 'ALIGNED' }), - ) - stubGitDiff('diff --git a/x b/x\n') - mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' }) - - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - summary: 'Long enough summary describing the deviation rationale clearly.', - })) as { content: { text: string }[] } - const body = JSON.parse(result.content[0].text) - expect(body.allowed_for_done).toBe(false) - expect(body.reason).toMatch(/ALIGNED/) - }) - - it('auto-fills base_sha from previous DONE execution head_sha', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ order: 1, base_sha: null }), - ) - mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({ - head_sha: 'prev-head-sha', - }) - stubGitDiff('diff\n') - mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' }) - - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - })) as { content: { text: string }[] } - const body = JSON.parse(result.content[0].text) - expect(body.base_sha).toBe('prev-head-sha') - - // Persisted back to row - const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls - const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha') - expect(baseShaPersist).toBeDefined() - }) - - it('errors when base_sha cannot be derived (no prior DONE)', async () => { - mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( - execRecord({ order: 2, base_sha: null }), - ) - mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null) - - const server = makeServer() - const result = (await server.call({ - execution_id: 'exec-1', - worktree_path: '/tmp/wt', - })) as { content: { text: string }[]; isError?: boolean } - expect(result.isError).toBe(true) - expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/) - }) -}) diff --git a/__tests__/verify/classify-delete.test.ts b/__tests__/verify/classify-delete.test.ts deleted file mode 100644 index ecdab9a..0000000 --- a/__tests__/verify/classify-delete.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { classifyDiffAgainstPlan } from '../../src/verify/classify.js' - -describe('classify — delete-only commits (PBI-47 C5)', () => { - it('returns ALIGNED when the deleted path is in the plan', () => { - const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx -deleted file mode 100644 -index 1234567..0000000 ---- a/app/todos/page.tsx -+++ /dev/null -@@ -1,3 +0,0 @@ --export default function TodosPage() { -- return null --}` - - const plan = '- Verwijder `app/todos/page.tsx`\n- Verwijder gerelateerde imports' - - const result = classifyDiffAgainstPlan({ diff, plan }) - expect(result.result).toBe('ALIGNED') - }) - - it('returns ALIGNED for multi-file delete-only when both paths in plan', () => { - const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx -deleted file mode 100644 ---- a/app/todos/page.tsx -+++ /dev/null -@@ -1,2 +0,0 @@ --line 1 --line 2 -diff --git a/components/todo-list.tsx b/components/todo-list.tsx -deleted file mode 100644 ---- a/components/todo-list.tsx -+++ /dev/null -@@ -1,1 +0,0 @@ --line` - - const plan = '- `app/todos/page.tsx`\n- `components/todo-list.tsx`' - const result = classifyDiffAgainstPlan({ diff, plan }) - expect(result.result).toBe('ALIGNED') - }) - - it('returns PARTIAL when only some plan deletes appear in the diff', () => { - const diff = `diff --git a/a.ts b/a.ts -deleted file mode 100644 ---- a/a.ts -+++ /dev/null -@@ -1,1 +0,0 @@ --x` - - const plan = '- `a.ts`\n- `b.ts`' // b.ts missing - const result = classifyDiffAgainstPlan({ diff, plan }) - expect(result.result).toBe('PARTIAL') - }) - - it('returns EMPTY for a no-op diff', () => { - const result = classifyDiffAgainstPlan({ diff: '', plan: 'irrelevant' }) - expect(result.result).toBe('EMPTY') - }) -}) diff --git a/__tests__/verify/classify.test.ts b/__tests__/verify/classify.test.ts index 968e125..1658e36 100644 --- a/__tests__/verify/classify.test.ts +++ b/__tests__/verify/classify.test.ts @@ -163,53 +163,3 @@ describe('classifyDiffAgainstPlan — delete-only commits', () => { expect(r.result).toBe('EMPTY') }) }) - -// Pseudo-paths in plans (code-snippets, attribute-syntax, ellipses) moeten -// niet als plan-paden meetellen — anders krijg je PARTIAL terwijl het werk -// volledig gedaan is. Regression-guard voor T-815-incident (sprint -// cmoyiu4yd000zf917acq9twtr, 2026-05-09). -describe('classifyDiffAgainstPlan — plan met pseudo-paths', () => { - it('negeert `data-debug-label="..."` als pseudo-pad en classificeert ALIGNED', () => { - const plan = [ - 'Verwijder alle voorkomens van `data-debug-label="..."` uit:', - '', - '- `app/components/shared/status-bar.tsx`', - '- `app/components/shared/header.tsx`', - ].join('\n') - const diff = makeDiff([ - 'app/components/shared/status-bar.tsx', - 'app/components/shared/header.tsx', - ]) - const r = classifyDiffAgainstPlan({ diff, plan }) - expect(r.result).toBe('ALIGNED') - }) - - it('negeert ellipsis-tokens (drie of meer dots) als pad', () => { - const plan = 'Refactor `foo(...)` naar `bar()`. Files: `src/a.ts`.' - const diff = makeDiff(['src/a.ts']) - const r = classifyDiffAgainstPlan({ diff, plan }) - expect(r.result).toBe('ALIGNED') - }) - - it('negeert tokens met operators/quotes als pad', () => { - const plan = 'Wijzig `props={x: 1}` en `useState()` in `src/c.tsx`.' - const diff = makeDiff(['src/c.tsx']) - const r = classifyDiffAgainstPlan({ diff, plan }) - expect(r.result).toBe('ALIGNED') - }) - - it('accepteert package.json en andere extension-only paths', () => { - const plan = 'Update `package.json` en `tsconfig.json`.' - const diff = makeDiff(['package.json', 'tsconfig.json']) - const r = classifyDiffAgainstPlan({ diff, plan }) - expect(r.result).toBe('ALIGNED') - }) - - it('blijft PARTIAL retourneren wanneer een echt plan-pad ontbreekt', () => { - const plan = 'Wijzig `src/foo.ts` en `src/bar.ts`. Verwijder `data-x="..."`.' - const diff = makeDiff(['src/foo.ts']) - const r = classifyDiffAgainstPlan({ diff, plan }) - expect(r.result).toBe('PARTIAL') - expect(r.reasoning).toMatch(/bar\.ts/) - }) -}) diff --git a/__tests__/verify/verify-scope.test.ts b/__tests__/verify/verify-scope.test.ts deleted file mode 100644 index ebfeb36..0000000 --- a/__tests__/verify/verify-scope.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import * as os from 'node:os' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { getDiffInWorktree } from '../../src/tools/verify-task-against-plan.js' - -const exec = promisify(execFile) - -describe('verify scope per-job (PBI-47 P0)', () => { - let tmpRepo: string - let baseSha: string - let task1Sha: string - - beforeAll(async () => { - tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'verify-scope-')) - await exec('git', ['init', '-b', 'main'], { cwd: tmpRepo }) - await exec('git', ['config', 'user.email', 't@t.local'], { cwd: tmpRepo }) - await exec('git', ['config', 'user.name', 'Test'], { cwd: tmpRepo }) - await fs.writeFile(path.join(tmpRepo, 'README.md'), '# init\n') - await exec('git', ['add', '-A'], { cwd: tmpRepo }) - await exec('git', ['commit', '-m', 'init'], { cwd: tmpRepo }) - const baseRev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo }) - baseSha = baseRev.stdout.trim() - - // Simulate task 1: add a.ts - await fs.writeFile(path.join(tmpRepo, 'a.ts'), 'task 1\n') - await exec('git', ['add', '-A'], { cwd: tmpRepo }) - await exec('git', ['commit', '-m', 'task 1'], { cwd: tmpRepo }) - const t1Rev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo }) - task1Sha = t1Rev.stdout.trim() - - // Simulate task 2: add b.ts - await fs.writeFile(path.join(tmpRepo, 'b.ts'), 'task 2\n') - await exec('git', ['add', '-A'], { cwd: tmpRepo }) - await exec('git', ['commit', '-m', 'task 2'], { cwd: tmpRepo }) - }) - - afterAll(async () => { - await fs.rm(tmpRepo, { recursive: true, force: true }) - }) - - it('diff vs base = origin/main → both task 1 and task 2 visible', async () => { - const diff = await getDiffInWorktree(tmpRepo, baseSha) - expect(diff).toContain('a.ts') - expect(diff).toContain('b.ts') - }) - - it('diff vs base = task1_sha → only task 2 visible', async () => { - const diff = await getDiffInWorktree(tmpRepo, task1Sha) - expect(diff).not.toContain('a.ts') - expect(diff).toContain('b.ts') - }) -}) diff --git a/__tests__/wait-for-job-branch-resolution.test.ts b/__tests__/wait-for-job-branch-resolution.test.ts deleted file mode 100644 index b85081f..0000000 --- a/__tests__/wait-for-job-branch-resolution.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../src/prisma.js', () => ({ - prisma: { - claudeJob: { - findUnique: vi.fn(), - findFirst: vi.fn(), - }, - }, -})) - -import { prisma } from '../src/prisma.js' -import { resolveBranchForJob } from '../src/tools/wait-for-job.js' - -const mockPrisma = prisma as unknown as { - claudeJob: { - findUnique: ReturnType - findFirst: ReturnType - } -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('resolveBranchForJob — sprint-aware', () => { - it('SPRINT-mode: kiest feat/sprint- en marks reused=false bij eerste task', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-cuid-12345678', - sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' }, - }) - mockPrisma.claudeJob.findFirst.mockResolvedValue(null) - - const result = await resolveBranchForJob('job-1', 'story-anything') - - expect(result.branchName).toBe('feat/sprint-12345678') - expect(result.reused).toBe(false) - }) - - it('SPRINT-mode: marks reused=true wanneer sibling al de branch gebruikt', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-cuid-12345678', - sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' }, - }) - mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/sprint-12345678' }) - - const result = await resolveBranchForJob('job-2', 'story-anything') - - expect(result.branchName).toBe('feat/sprint-12345678') - expect(result.reused).toBe(true) - }) - - it('STORY-mode (sprint-flow): valt terug op story-branch via legacy-pad', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: 'run-cuid-12345678', - sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'STORY' }, - }) - mockPrisma.claudeJob.findFirst.mockResolvedValue(null) - - const result = await resolveBranchForJob('job-1', 'story-cuid-87654321') - - expect(result.branchName).toBe('feat/story-87654321') - expect(result.reused).toBe(false) - }) - - it('Legacy (geen sprint_run): bestaand gedrag — feat/story-', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: null, - sprint_run: null, - }) - mockPrisma.claudeJob.findFirst.mockResolvedValue(null) - - const result = await resolveBranchForJob('job-1', 'story-cuid-87654321') - - expect(result.branchName).toBe('feat/story-87654321') - expect(result.reused).toBe(false) - }) - - it('Legacy: hergebruik branch wanneer sibling-job in dezelfde story al een branch heeft', async () => { - mockPrisma.claudeJob.findUnique.mockResolvedValue({ - sprint_run_id: null, - sprint_run: null, - }) - mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/story-87654321' }) - - const result = await resolveBranchForJob('job-2', 'story-cuid-87654321') - - expect(result.branchName).toBe('feat/story-87654321') - expect(result.reused).toBe(true) - }) -}) diff --git a/__tests__/wait-for-job-worktree.test.ts b/__tests__/wait-for-job-worktree.test.ts index d36e08f..c594fab 100644 --- a/__tests__/wait-for-job-worktree.test.ts +++ b/__tests__/wait-for-job-worktree.test.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises' vi.mock('../src/prisma.js', () => ({ prisma: { $executeRaw: vi.fn(), - claudeJob: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, + claudeJob: { findFirst: vi.fn() }, product: { findUnique: vi.fn() }, }, })) @@ -21,15 +21,13 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool const mockPrisma = prisma as unknown as { $executeRaw: ReturnType - claudeJob: { findFirst: ReturnType; findUnique: ReturnType; update: ReturnType } + claudeJob: { findFirst: ReturnType } product: { findUnique: ReturnType } } const mockCreateWorktree = createWorktreeForJob as ReturnType beforeEach(() => { vi.clearAllMocks() - // Default: legacy job zonder sprint_run (oude flow). - mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null }) }) describe('resolveRepoRoot', () => { diff --git a/package-lock.json b/package-lock.json index 3514598..dd27830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,19 @@ { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@types/proper-lockfile": "^4.1.4", "pg": "^8.13.1", - "proper-lockfile": "^4.1.2", "yaml": "^2.8.4", "zod": "^4.0.0" }, @@ -1329,15 +1327,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/proper-lockfile": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", - "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "*" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1349,12 +1338,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/retry": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", - "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -2349,6 +2332,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, "license": "ISC" }, "node_modules/grammex": { @@ -3293,6 +3277,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3304,6 +3289,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, "license": "ISC" }, "node_modules/proxy-addr": { @@ -3458,6 +3444,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" diff --git a/package.json b/package.json index 0cbcf56..913b21c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { @@ -32,9 +32,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@types/proper-lockfile": "^4.1.4", "pg": "^8.13.1", - "proper-lockfile": "^4.1.2", "yaml": "^2.8.4", "zod": "^4.0.0" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d854a58..c6c4aa3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,6 +2,7 @@ generator client { provider = "prisma-client-js" } + datasource db { provider = "postgresql" } @@ -56,7 +57,6 @@ enum TaskStatus { REVIEW DONE FAILED - EXCLUDED } enum LogType { @@ -71,9 +71,8 @@ enum TestStatus { } enum SprintStatus { - OPEN - CLOSED - ARCHIVED + ACTIVE + COMPLETED FAILED } @@ -89,7 +88,6 @@ enum SprintRunStatus { enum PrStrategy { SPRINT STORY - SPRINT_BATCH } enum IdeaStatus { @@ -100,9 +98,6 @@ enum IdeaStatus { PLANNING PLAN_FAILED PLAN_READY - REVIEWING_PLAN - PLAN_REVIEW_FAILED - PLAN_REVIEWED PLANNED } @@ -110,17 +105,7 @@ enum ClaudeJobKind { TASK_IMPLEMENTATION IDEA_GRILL IDEA_MAKE_PLAN - IDEA_REVIEW_PLAN PLAN_CHAT - SPRINT_IMPLEMENTATION -} - -enum SprintTaskExecutionStatus { - PENDING - RUNNING - DONE - FAILED - SKIPPED } enum IdeaLogType { @@ -128,7 +113,6 @@ enum IdeaLogType { NOTE GRILL_RESULT PLAN_RESULT - PLAN_REVIEW_RESULT STATUS_CHANGE JOB_EVENT } @@ -152,7 +136,6 @@ model User { active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) idea_code_counter Int @default(0) min_quota_pct Int @default(20) - settings Json @default("{}") created_at DateTime @default(now()) updated_at DateTime @updatedAt roles UserRole[] @@ -167,7 +150,6 @@ model User { claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") - push_subscriptions PushSubscription[] @@index([active_product_id]) @@map("users") @@ -209,9 +191,6 @@ model Product { definition_of_done String auto_pr Boolean @default(false) pr_strategy PrStrategy @default(SPRINT) - preferred_model String? - thinking_budget_default Int? - preferred_permission_mode String? archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -306,9 +285,8 @@ model Sprint { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - code String @db.VarChar(30) sprint_goal String - status SprintStatus @default(OPEN) + status SprintStatus @default(ACTIVE) start_date DateTime? @db.Date end_date DateTime? @db.Date created_at DateTime @default(now()) @@ -317,33 +295,28 @@ model Sprint { tasks Task[] sprint_runs SprintRun[] - @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } model SprintRun { - id String @id @default(cuid()) - sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) - sprint_id String - started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) - started_by_id String - status SprintRunStatus @default(QUEUED) - pr_strategy PrStrategy - branch String? - pr_url String? - started_at DateTime? - finished_at DateTime? - failure_reason String? - failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) - failed_task_id String? - pause_context Json? - previous_run_id String? @unique - previous_run SprintRun? @relation("SprintRunChain", fields: [previous_run_id], references: [id], onDelete: SetNull) - next_run SprintRun? @relation("SprintRunChain") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - jobs ClaudeJob[] + id String @id @default(cuid()) + sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) + sprint_id String + started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) + started_by_id String + status SprintRunStatus @default(QUEUED) + pr_strategy PrStrategy + branch String? + pr_url String? + started_at DateTime? + finished_at DateTime? + failure_reason String? + failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) + failed_task_id String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + jobs ClaudeJob[] @@index([sprint_id, status]) @@index([started_by_id, status]) @@ -351,34 +324,32 @@ model SprintRun { } model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - code String @db.VarChar(30) - title String - description String? - implementation_plan String? - priority Int - sort_order Float - status TaskStatus @default(TO_DO) - verify_only Boolean @default(false) - verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) - requires_opus Boolean @default(false) + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + code String @db.VarChar(30) + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + verify_only Boolean @default(false) + verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) // Override product.repo_url for branch/worktree/push purposes. Set when // a task targets a different repo than its parent product (e.g. an // MCP-server task tracked under the main product's PBI). Falls back to // product.repo_url when null. - repo_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - claude_questions ClaudeQuestion[] - claude_jobs ClaudeJob[] - sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") - sprint_task_executions SprintTaskExecution[] + repo_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] + sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@ -388,20 +359,20 @@ model Task { } model ClaudeJob { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) + task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) task_id String? - idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) idea_id String? - sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) + sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) sprint_run_id String? - kind ClaudeJobKind @default(TASK_IMPLEMENTATION) - status ClaudeJobStatus @default(QUEUED) - claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) + kind ClaudeJobKind @default(TASK_IMPLEMENTATION) + status ClaudeJobStatus @default(QUEUED) + claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) claimed_by_token_id String? claimed_at DateTime? started_at DateTime? @@ -413,22 +384,14 @@ model ClaudeJob { output_tokens Int? cache_read_tokens Int? cache_write_tokens Int? - requested_model String? - requested_thinking_budget Int? - requested_permission_mode String? - actual_thinking_tokens Int? plan_snapshot String? - base_sha String? - head_sha String? branch String? pr_url String? summary String? error String? - retry_count Int @default(0) - lease_until DateTime? - task_executions SprintTaskExecution[] @relation("SprintJobExecutions") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + retry_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, status]) @@index([task_id, status]) @@ -436,41 +399,9 @@ model ClaudeJob { @@index([sprint_run_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) - @@index([status, lease_until]) @@map("claude_jobs") } -// PBI-50: frozen scope-snapshot per SPRINT_IMPLEMENTATION-claim. Bij claim -// wordt voor elke TO_DO-task in scope één PENDING-record gemaakt met -// implementation_plan + verify_required gesnapshot. Worker en gate werken -// uitsluitend op deze rows; latere wijzigingen aan Task hebben geen -// invloed op de lopende batch. -model SprintTaskExecution { - id String @id @default(cuid()) - sprint_job ClaudeJob @relation("SprintJobExecutions", fields: [sprint_job_id], references: [id], onDelete: Cascade) - sprint_job_id String - task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String - order Int - plan_snapshot String @db.Text - verify_required_snapshot VerifyRequired - verify_only_snapshot Boolean @default(false) - base_sha String? - head_sha String? - status SprintTaskExecutionStatus @default(PENDING) - verify_result VerifyResult? - verify_summary String? @db.Text - skip_reason String? @db.Text - started_at DateTime? - finished_at DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@unique([sprint_job_id, task_id]) - @@index([sprint_job_id, order]) - @@map("sprint_task_executions") -} - model ModelPrice { id String @id @default(cuid()) model_id String @unique @@ -516,24 +447,22 @@ model ProductMember { } model Idea { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - code String @db.VarChar(30) - title String - description String? @db.VarChar(4000) - grill_md String? @db.Text - plan_md String? @db.Text - plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) - reviewed_at DateTime? // When last reviewed - pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) - pbi_id String? @unique - status IdeaStatus @default(DRAFT) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + code String @db.VarChar(30) + title String + description String? @db.VarChar(4000) + grill_md String? @db.Text + plan_md String? @db.Text + pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) + pbi_id String? @unique + status IdeaStatus @default(DRAFT) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt questions ClaudeQuestion[] jobs ClaudeJob[] @@ -638,18 +567,3 @@ model ClaudeQuestion { @@index([status, expires_at]) @@map("claude_questions") } - -model PushSubscription { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - endpoint String @unique - p256dh String - auth String - user_agent String? - created_at DateTime @default(now()) - last_used_at DateTime @default(now()) - - @@index([user_id]) - @@map("push_subscriptions") -} diff --git a/src/cancel/pbi-cascade.ts b/src/cancel/pbi-cascade.ts index 19b1b0e..05a014f 100644 --- a/src/cancel/pbi-cascade.ts +++ b/src/cancel/pbi-cascade.ts @@ -13,7 +13,6 @@ import { getPullRequestState, } from '../git/pr.js' import { deleteRemoteBranch } from '../git/push.js' -import { releaseLocksOnTerminal } from '../git/job-locks.js' export type CascadeOutcome = { cancelled_job_ids: string[] @@ -47,7 +46,6 @@ async function runCascade(failedJobId: string): Promise { select: { id: true, kind: true, - status: true, product_id: true, task_id: true, branch: true, @@ -66,8 +64,6 @@ async function runCascade(failedJobId: string): Promise { if (!failedJob) return EMPTY if (failedJob.kind !== 'TASK_IMPLEMENTATION') return EMPTY - // SKIPPED is een no-op exit (zie update_job_status). Geen cascade naar siblings. - if (failedJob.status === 'SKIPPED') return EMPTY const pbi = failedJob.task?.story?.pbi if (!pbi) return EMPTY @@ -92,9 +88,6 @@ async function runCascade(failedJobId: string): Promise { error: 'cancelled_by_pbi_failure', }, }) - // PBI-9: release product-worktree locks for cancelled jobs. - // No-op for jobs without registered locks (TASK_IMPLEMENTATION). - for (const j of eligible) await releaseLocksOnTerminal(j.id) } const outcome: CascadeOutcome = { @@ -197,21 +190,12 @@ async function runCascade(failedJobId: string): Promise { // 4. Persist a trace on the failed-job's error field so the operator can // follow up. Use a structured one-liner to keep the column readable. - // Append to the existing error (separated by '\n---\n') so the original - // failure reason is preserved instead of being overwritten by the trace. const trace = formatTrace(outcome) if (trace) { try { - const fresh = await prisma.claudeJob.findUnique({ - where: { id: failedJobId }, - select: { error: true }, - }) - const merged = fresh?.error - ? `${fresh.error}\n---\n${trace}`.slice(0, 1900) - : trace.slice(0, 1900) await prisma.claudeJob.update({ where: { id: failedJobId }, - data: { error: merged }, + data: { error: trace.slice(0, 1900) }, }) } catch (err) { console.warn(`[pbi-cascade] failed to persist trace for ${failedJobId}:`, err) diff --git a/src/flow/effects.ts b/src/flow/effects.ts deleted file mode 100644 index a12ee69..0000000 --- a/src/flow/effects.ts +++ /dev/null @@ -1,192 +0,0 @@ -// PBI-9 + PBI-47: declarative effects produced by pure transitions. -// Executor handles each effect idempotently; failures are logged, not thrown. - -export type PauseContext = { - pause_reason: 'MERGE_CONFLICT' - pr_url: string - pr_head_sha: string - conflict_files: string[] - claude_question_id: string - resume_instructions: string - paused_at: string -} - -export type FlowEffect = - | { type: 'RELEASE_WORKTREE_LOCKS'; jobId: string } - | { type: 'ENABLE_AUTO_MERGE'; prUrl: string; expectedHeadSha: string } - | { type: 'MARK_PR_READY'; prUrl: string } - | { - type: 'CREATE_CLAUDE_QUESTION' - sprintRunId: string - prUrl: string - files: string[] - } - | { type: 'CLOSE_CLAUDE_QUESTION'; questionId: string } - | { - type: 'SET_SPRINT_RUN_STATUS' - sprintRunId: string - status: 'QUEUED' | 'RUNNING' | 'PAUSED' | 'DONE' | 'FAILED' | 'CANCELLED' - pauseContextDraft?: Omit - clearPauseContext?: boolean - } - -export type AutoMergeOutcome = - | { effect: 'ENABLE_AUTO_MERGE'; ok: true } - | { - effect: 'ENABLE_AUTO_MERGE' - ok: false - reason: 'CHECKS_FAILED' | 'MERGE_CONFLICT' | 'GH_AUTH_ERROR' | 'AUTO_MERGE_NOT_ALLOWED' | 'UNKNOWN' - stderr: string - } - -/** - * Execute a list of effects in order. Returns outcome objects only for - * effects whose result the caller needs to react to (auto-merge fail - * triggers MERGE_CONFLICT-event in update-job-status). Other failures - * are logged but swallowed. - * - * CREATE_CLAUDE_QUESTION → SET_SPRINT_RUN_STATUS chains: the question_id - * created in the first effect is injected into the pause_context of the - * second. - */ -export async function executeEffects( - effects: FlowEffect[], -): Promise { - const outcomes: AutoMergeOutcome[] = [] - let lastQuestionId: string | undefined - for (const effect of effects) { - try { - if (effect.type === 'CREATE_CLAUDE_QUESTION') { - lastQuestionId = await createOrReuseClaudeQuestion(effect) - continue - } - if (effect.type === 'SET_SPRINT_RUN_STATUS') { - await applySprintRunStatus(effect, lastQuestionId) - continue - } - const outcome = await executeEffect(effect) - if (outcome) outcomes.push(outcome) - } catch (err) { - console.warn(`[effects] effect ${effect.type} failed (idempotent skip):`, err) - } - } - return outcomes -} - -async function executeEffect(effect: FlowEffect): Promise { - switch (effect.type) { - case 'RELEASE_WORKTREE_LOCKS': { - const { releaseLocksOnTerminal } = await import('../git/job-locks.js') - await releaseLocksOnTerminal(effect.jobId) - return undefined - } - case 'ENABLE_AUTO_MERGE': { - const { enableAutoMergeOnPr } = await import('../git/pr.js') - const result = await enableAutoMergeOnPr({ - prUrl: effect.prUrl, - expectedHeadSha: effect.expectedHeadSha, - }) - if (result.ok) return { effect: 'ENABLE_AUTO_MERGE', ok: true } - return { effect: 'ENABLE_AUTO_MERGE', ok: false, reason: result.reason, stderr: result.stderr } - } - case 'MARK_PR_READY': { - const { markPullRequestReady } = await import('../git/pr.js') - const result = await markPullRequestReady({ prUrl: effect.prUrl }) - if ('error' in result) { - console.warn(`[effects] MARK_PR_READY failed for ${effect.prUrl}: ${result.error}`) - } - return undefined - } - case 'CLOSE_CLAUDE_QUESTION': { - const { prisma } = await import('../prisma.js') - await prisma.claudeQuestion.updateMany({ - where: { id: effect.questionId, status: 'open' }, - data: { status: 'closed' }, - }) - return undefined - } - // CREATE_CLAUDE_QUESTION + SET_SPRINT_RUN_STATUS handled in executeEffects. - case 'CREATE_CLAUDE_QUESTION': - case 'SET_SPRINT_RUN_STATUS': - return undefined - } -} - -async function createOrReuseClaudeQuestion(effect: { - sprintRunId: string - prUrl: string - files: string[] -}): Promise { - const { prisma } = await import('../prisma.js') - - // Reuse existing open question for the same SprintRun + PR if present. - const existing = await prisma.claudeQuestion.findFirst({ - where: { - status: 'open', - options: { path: ['sprint_run_id'], equals: effect.sprintRunId } as never, - }, - orderBy: { created_at: 'desc' }, - select: { id: true }, - }) - if (existing) return existing.id - - // Need product_id + asker (user) to create. Resolve via SprintRun. - const sprintRun = await prisma.sprintRun.findUnique({ - where: { id: effect.sprintRunId }, - select: { - started_by_id: true, - sprint: { select: { product_id: true } }, - }, - }) - if (!sprintRun) { - throw new Error(`SprintRun ${effect.sprintRunId} not found`) - } - - const fileList = - effect.files.length === 0 - ? '(unknown files — check the PR)' - : effect.files.slice(0, 5).join(', ') - + (effect.files.length > 5 ? ` + ${effect.files.length - 5} more` : '') - - const created = await prisma.claudeQuestion.create({ - data: { - product_id: sprintRun.sprint.product_id, - asked_by: sprintRun.started_by_id, - question: - `Merge-conflict on ${effect.prUrl}. Conflict files: ${fileList}. ` - + `Resolve on the branch and push, then resume the sprint.`, - options: { - sprint_run_id: effect.sprintRunId, - pr_url: effect.prUrl, - conflict_files: effect.files, - }, - status: 'open', - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days - }, - select: { id: true }, - }) - return created.id -} - -async function applySprintRunStatus( - effect: Extract, - lastQuestionId: string | undefined, -): Promise { - const { prisma, Prisma } = await (async () => { - const mod = await import('../prisma.js') - const prismaPkg = await import('@prisma/client') - return { prisma: mod.prisma, Prisma: prismaPkg.Prisma } - })() - - const data: Record = { status: effect.status } - if (effect.pauseContextDraft && lastQuestionId) { - data.pause_context = { - ...effect.pauseContextDraft, - claude_question_id: lastQuestionId, - } - } - if (effect.clearPauseContext) { - data.pause_context = Prisma.JsonNull - } - await prisma.sprintRun.update({ where: { id: effect.sprintRunId }, data }) -} diff --git a/src/flow/pr-flow.ts b/src/flow/pr-flow.ts deleted file mode 100644 index f1daa12..0000000 --- a/src/flow/pr-flow.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { FlowEffect } from './effects.js' -import type { AutoMergeFailReason } from '../git/pr.js' - -export type PrStrategy = 'STORY' | 'SPRINT' - -export type PrFlowState = - | { kind: 'none'; strategy: PrStrategy } - | { kind: 'branch_pushed'; strategy: PrStrategy; prUrl?: string } - | { kind: 'pr_opened'; strategy: 'STORY'; prUrl: string } - | { kind: 'draft_opened'; strategy: 'SPRINT'; prUrl: string } - | { kind: 'waiting_for_checks'; strategy: 'STORY'; prUrl: string; headSha: string } - | { kind: 'auto_merge_enabled'; strategy: 'STORY'; prUrl: string; headSha: string } - | { kind: 'ready_for_review'; strategy: 'SPRINT'; prUrl: string } - | { kind: 'merged'; strategy: PrStrategy; prUrl: string } - | { kind: 'checks_failed'; strategy: PrStrategy; prUrl: string } - | { kind: 'merge_conflict_paused'; strategy: PrStrategy; prUrl: string; headSha: string } - -export type PrFlowEvent = - | { type: 'PR_CREATED'; prUrl: string } - | { type: 'TASK_DONE'; taskId: string; headSha: string } - | { type: 'STORY_COMPLETED'; storyId: string; headSha: string } - | { type: 'SPRINT_COMPLETED'; sprintRunId: string } - | { type: 'MERGE_RESULT'; reason?: AutoMergeFailReason } - -export type TransitionResult = { nextState: PrFlowState; effects: FlowEffect[] } - -export function transition(state: PrFlowState, event: PrFlowEvent): TransitionResult { - if (state.strategy === 'STORY') { - switch (state.kind) { - case 'none': - case 'branch_pushed': - if (event.type === 'PR_CREATED') { - return { - nextState: { kind: 'pr_opened', strategy: 'STORY', prUrl: event.prUrl }, - effects: [], - } - } - break - case 'pr_opened': - if (event.type === 'STORY_COMPLETED') { - return { - nextState: { - kind: 'waiting_for_checks', - strategy: 'STORY', - prUrl: state.prUrl, - headSha: event.headSha, - }, - effects: [ - { type: 'ENABLE_AUTO_MERGE', prUrl: state.prUrl, expectedHeadSha: event.headSha }, - ], - } - } - break - case 'waiting_for_checks': - if (event.type === 'MERGE_RESULT' && !event.reason) { - return { - nextState: { - kind: 'auto_merge_enabled', - strategy: 'STORY', - prUrl: state.prUrl, - headSha: state.headSha, - }, - effects: [], - } - } - if (event.type === 'MERGE_RESULT' && event.reason === 'MERGE_CONFLICT') { - return { - nextState: { - kind: 'merge_conflict_paused', - strategy: 'STORY', - prUrl: state.prUrl, - headSha: state.headSha, - }, - effects: [], - } - } - if (event.type === 'MERGE_RESULT' && event.reason === 'CHECKS_FAILED') { - return { - nextState: { kind: 'checks_failed', strategy: 'STORY', prUrl: state.prUrl }, - effects: [], - } - } - break - } - } - - if (state.strategy === 'SPRINT') { - switch (state.kind) { - case 'none': - case 'branch_pushed': - if (event.type === 'PR_CREATED') { - return { - nextState: { kind: 'draft_opened', strategy: 'SPRINT', prUrl: event.prUrl }, - effects: [], - } - } - break - case 'draft_opened': - if (event.type === 'SPRINT_COMPLETED') { - return { - nextState: { kind: 'ready_for_review', strategy: 'SPRINT', prUrl: state.prUrl }, - effects: [{ type: 'MARK_PR_READY', prUrl: state.prUrl }], - } - } - break - } - } - - return { nextState: state, effects: [] } -} diff --git a/src/flow/sprint-run.ts b/src/flow/sprint-run.ts deleted file mode 100644 index 4acb54d..0000000 --- a/src/flow/sprint-run.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { FlowEffect, PauseContext } from './effects.js' - -export type SprintRunStateKind = - | 'queued' - | 'running' - | 'paused_merge_conflict' - | 'done' - | 'failed' - | 'cancelled' - -export type SprintRunState = { - kind: SprintRunStateKind - sprintRunId: string - pauseContext?: PauseContext -} - -export type SprintRunEvent = - | { type: 'CLAIM_FIRST_JOB' } - | { type: 'TASK_DONE'; taskId: string } - | { type: 'TASK_FAILED'; taskId: string; error: string } - | { - type: 'MERGE_CONFLICT' - prUrl: string - prHeadSha: string - conflictFiles: string[] - resumeInstructions: string - } - | { type: 'USER_RESUMED' } - | { type: 'USER_CANCELLED' } - | { type: 'ALL_DONE' } - -export type TransitionResult = { nextState: SprintRunState; effects: FlowEffect[] } - -export function transition(state: SprintRunState, event: SprintRunEvent): TransitionResult { - switch (state.kind) { - case 'queued': - if (event.type === 'CLAIM_FIRST_JOB') { - return { - nextState: { ...state, kind: 'running' }, - effects: [ - { type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'RUNNING' }, - ], - } - } - break - case 'running': - if (event.type === 'TASK_DONE') { - return { nextState: state, effects: [] } - } - if (event.type === 'TASK_FAILED') { - return { - nextState: { ...state, kind: 'failed' }, - effects: [ - { type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'FAILED' }, - ], - } - } - if (event.type === 'ALL_DONE') { - return { - nextState: { ...state, kind: 'done' }, - effects: [ - { type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'DONE' }, - ], - } - } - if (event.type === 'MERGE_CONFLICT') { - const pauseContextDraft: Omit = { - pause_reason: 'MERGE_CONFLICT', - pr_url: event.prUrl, - pr_head_sha: event.prHeadSha, - conflict_files: event.conflictFiles, - resume_instructions: event.resumeInstructions, - paused_at: new Date().toISOString(), - } - return { - nextState: { ...state, kind: 'paused_merge_conflict' }, - effects: [ - { - type: 'CREATE_CLAUDE_QUESTION', - sprintRunId: state.sprintRunId, - prUrl: event.prUrl, - files: event.conflictFiles, - }, - { - type: 'SET_SPRINT_RUN_STATUS', - sprintRunId: state.sprintRunId, - status: 'PAUSED', - pauseContextDraft, - }, - ], - } - } - if (event.type === 'USER_CANCELLED') { - return { - nextState: { ...state, kind: 'cancelled' }, - effects: [ - { type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'CANCELLED' }, - ], - } - } - break - case 'paused_merge_conflict': - if (event.type === 'USER_RESUMED') { - const closeQuestionEffects: FlowEffect[] = state.pauseContext - ? [{ type: 'CLOSE_CLAUDE_QUESTION', questionId: state.pauseContext.claude_question_id }] - : [] - return { - nextState: { ...state, kind: 'running', pauseContext: undefined }, - effects: [ - ...closeQuestionEffects, - { - type: 'SET_SPRINT_RUN_STATUS', - sprintRunId: state.sprintRunId, - status: 'RUNNING', - clearPauseContext: true, - }, - ], - } - } - if (event.type === 'USER_CANCELLED') { - return { - nextState: { ...state, kind: 'cancelled', pauseContext: undefined }, - effects: [ - { - type: 'SET_SPRINT_RUN_STATUS', - sprintRunId: state.sprintRunId, - status: 'CANCELLED', - clearPauseContext: true, - }, - ], - } - } - break - } - return { nextState: state, effects: [] } -} diff --git a/src/flow/worktree-lease.ts b/src/flow/worktree-lease.ts deleted file mode 100644 index 2cc1458..0000000 --- a/src/flow/worktree-lease.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { FlowEffect } from './effects.js' - -export type WorktreeLeaseState = - | { kind: 'idle' } - | { kind: 'acquiring_lock'; jobId: string; productIds: string[] } - | { kind: 'creating_or_reusing'; jobId: string; productIds: string[] } - | { kind: 'syncing'; jobId: string; productIds: string[] } - | { kind: 'ready'; jobId: string; productIds: string[] } - | { kind: 'releasing'; jobId: string } - | { kind: 'released'; jobId: string } - | { kind: 'lock_timeout'; jobId: string; productIds: string[] } - | { kind: 'sync_failed'; jobId: string; productIds: string[]; error: string } - | { kind: 'stale_released'; jobId: string } - -export type WorktreeLeaseEvent = - | { type: 'JOB_CLAIMED'; jobId: string; productIds: string[] } - | { type: 'LOCK_ACQUIRED' } - | { type: 'LOCK_TIMEOUT' } - | { type: 'WORKTREE_READY' } - | { type: 'SYNC_DONE' } - | { type: 'SYNC_FAILED'; error: string } - | { type: 'JOB_TERMINAL'; jobId: string } - | { type: 'STALE_RESET'; jobId: string } - -export type TransitionResult = { - nextState: WorktreeLeaseState - effects: FlowEffect[] -} - -export function transition( - state: WorktreeLeaseState, - event: WorktreeLeaseEvent, -): TransitionResult { - switch (state.kind) { - case 'idle': - if (event.type === 'JOB_CLAIMED') { - return { - nextState: { kind: 'acquiring_lock', jobId: event.jobId, productIds: event.productIds }, - effects: [], - } - } - break - case 'acquiring_lock': - if (event.type === 'LOCK_ACQUIRED') { - return { - nextState: { kind: 'creating_or_reusing', jobId: state.jobId, productIds: state.productIds }, - effects: [], - } - } - if (event.type === 'LOCK_TIMEOUT') { - return { - nextState: { kind: 'lock_timeout', jobId: state.jobId, productIds: state.productIds }, - effects: [], - } - } - break - case 'creating_or_reusing': - if (event.type === 'WORKTREE_READY') { - return { - nextState: { kind: 'syncing', jobId: state.jobId, productIds: state.productIds }, - effects: [], - } - } - break - case 'syncing': - if (event.type === 'SYNC_DONE') { - return { - nextState: { kind: 'ready', jobId: state.jobId, productIds: state.productIds }, - effects: [], - } - } - if (event.type === 'SYNC_FAILED') { - return { - nextState: { - kind: 'sync_failed', - jobId: state.jobId, - productIds: state.productIds, - error: event.error, - }, - effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }], - } - } - break - case 'ready': - if (event.type === 'JOB_TERMINAL') { - return { - nextState: { kind: 'releasing', jobId: state.jobId }, - effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }], - } - } - if (event.type === 'STALE_RESET') { - return { - nextState: { kind: 'stale_released', jobId: state.jobId }, - effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }], - } - } - break - case 'releasing': - return { nextState: { kind: 'released', jobId: state.jobId }, effects: [] } - } - // Unknown or forbidden transition — keep current state, no effects - return { nextState: state, effects: [] } -} diff --git a/src/git/file-lock.ts b/src/git/file-lock.ts deleted file mode 100644 index 1fa2e4c..0000000 --- a/src/git/file-lock.ts +++ /dev/null @@ -1,38 +0,0 @@ -import lockfile from 'proper-lockfile' - -export async function acquireFileLock(lockPath: string): Promise<() => Promise> { - const release = await lockfile.lock(lockPath, { - realpath: false, - stale: 30_000, - update: 5_000, - retries: { retries: 60, factor: 1, minTimeout: 1_000, maxTimeout: 1_000 }, - }) - let released = false - return async () => { - if (released) return - released = true - await release() - } -} - -export async function acquireFileLocksOrdered( - lockPaths: string[], -): Promise<() => Promise> { - const sorted = [...lockPaths].sort() - const releases: Array<() => Promise> = [] - try { - for (const p of sorted) { - releases.push(await acquireFileLock(p)) - } - } catch (err) { - for (const r of releases.reverse()) { - await r().catch(() => {}) - } - throw err - } - return async () => { - for (const r of releases.reverse()) { - await r().catch(() => {}) - } - } -} diff --git a/src/git/job-locks.ts b/src/git/job-locks.ts deleted file mode 100644 index a7c1a05..0000000 --- a/src/git/job-locks.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { acquireFileLocksOrdered } from './file-lock.js' -import { - getProductWorktreeLockPath, - getWorktreeRoot, -} from './worktree-paths.js' -import { - getOrCreateProductWorktree, - syncProductWorktree, -} from './product-worktree.js' - -type JobReleases = Map Promise>> -const jobReleases: JobReleases = new Map() - -export async function setupProductWorktrees( - jobId: string, - productIds: string[], - resolveRepoRoot: (productId: string) => Promise, -): Promise> { - if (productIds.length === 0) return [] - - // Ensure parent dir exists so lockfile creation succeeds - await fs.mkdir(path.join(getWorktreeRoot(), '_products'), { recursive: true }) - - // Lock-first, alphabetically sorted (deadlock prevention for multi-product idea-jobs). - // Locks acquired in sorted order; output preserves caller's input order so that - // worktrees[0] is the primary product (Idea.product_id), regardless of how its - // id sorts alphabetically against secondary products. - const sorted = [...productIds].sort() - const lockPaths = sorted.map(getProductWorktreeLockPath) - const releaseAll = await acquireFileLocksOrdered(lockPaths) - registerJobLockReleases(jobId, [releaseAll]) - - // After lock-acquire, create/reuse worktrees and sync — iterate input order - // so callers get back [primary, ...secondaries] in their original sequence. - const out: Array<{ productId: string; worktreePath: string }> = [] - for (const productId of productIds) { - const repoRoot = await resolveRepoRoot(productId) - if (!repoRoot) continue - const { worktreePath } = await getOrCreateProductWorktree({ repoRoot, productId }) - await syncProductWorktree({ worktreePath }) - out.push({ productId, worktreePath }) - } - - return out -} - -export function registerJobLockReleases( - jobId: string, - releases: Array<() => Promise>, -): void { - const existing = jobReleases.get(jobId) ?? [] - jobReleases.set(jobId, [...existing, ...releases]) -} - -export async function releaseLocksOnTerminal(jobId: string): Promise { - const releases = jobReleases.get(jobId) - if (!releases) return // idempotent — already released or never locked - jobReleases.delete(jobId) - for (const release of releases) { - try { - await release() - } catch (err) { - console.warn(`[job-locks] release failed for job ${jobId}:`, err) - } - } -} - -// For tests -export function _resetJobReleasesForTest(): void { - jobReleases.clear() -} diff --git a/src/git/pr.ts b/src/git/pr.ts index e86d8fa..f30ac8e 100644 --- a/src/git/pr.ts +++ b/src/git/pr.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import * as path from 'node:path' -import { getWorktreeRoot } from './worktree-paths.js' +import * as os from 'node:os' const exec = promisify(execFile) @@ -10,25 +10,16 @@ export async function createPullRequest(opts: { branchName: string title: string body: string - /** Open as draft PR (mens moet 'm later ready-for-review zetten). Default false. */ - draft?: boolean - /** - * PBI-47 (P0): default changed to false. Auto-merge is now enabled - * separately via `enableAutoMergeOnPr` only on the **last task** of a - * STORY-mode story, with a head-SHA guard to prevent racing earlier - * task merges. Callers may still pass `true` for one-off PRs that - * are immediately ready to merge; in that case we use the new typed - * helper rather than the previous fire-and-forget gh call. - */ - enableAutoMerge?: boolean }): Promise<{ url: string } | { error: string }> { - const { worktreePath, branchName, title, body, draft = false, enableAutoMerge = false } = opts + const { worktreePath, branchName, title, body } = opts let url: string try { - const args = ['pr', 'create', '--title', title, '--body', body, '--head', branchName] - if (draft) args.push('--draft') - const { stdout } = await exec('gh', args, { cwd: worktreePath }) + const { stdout } = await exec( + 'gh', + ['pr', 'create', '--title', title, '--body', body, '--head', branchName], + { cwd: worktreePath }, + ) // gh prints the PR URL as the last non-empty line const lines = stdout.trim().split('\n').filter(Boolean) url = lines[lines.length - 1]?.trim() ?? '' @@ -47,82 +38,22 @@ export async function createPullRequest(opts: { return { error: `gh pr create failed: ${msg.slice(0, 300)}` } } - // Legacy opt-in: enableAutoMerge=true and not draft → fire the new typed - // helper without head-SHA guard (caller didn't supply one). Result is - // logged but not propagated — same shape as before. - if (enableAutoMerge && !draft) { - const result = await enableAutoMergeOnPr({ prUrl: url, cwd: worktreePath }) - if (!result.ok) { - console.warn( - `[createPullRequest] auto-merge enable failed for ${url}: ${result.reason} ${result.stderr.slice(0, 200)}`, - ) - } - } - - return { url } -} - -export type AutoMergeFailReason = - | 'CHECKS_FAILED' - | 'MERGE_CONFLICT' - | 'GH_AUTH_ERROR' - | 'AUTO_MERGE_NOT_ALLOWED' - | 'UNKNOWN' - -export type EnableAutoMergeResult = - | { ok: true } - | { ok: false; reason: AutoMergeFailReason; stderr: string } - -function classifyAutoMergeError(stderr: string): AutoMergeFailReason { - if (/conflict|not in mergeable state|dirty/i.test(stderr)) return 'MERGE_CONFLICT' - if (/checks? failed|status check|required check/i.test(stderr)) return 'CHECKS_FAILED' - if (/authentication|HTTP 401|HTTP 403|permission|gh auth/i.test(stderr)) return 'GH_AUTH_ERROR' - if (/auto-?merge.*not.*allowed|auto-?merge.*disabled/i.test(stderr)) return 'AUTO_MERGE_NOT_ALLOWED' - return 'UNKNOWN' -} - -/** - * Enable auto-merge (squash) on a PR with an optional head-SHA guard. - * - * PBI-47 (P0): when `expectedHeadSha` is provided we pass `--match-head-commit` - * so GitHub only activates auto-merge if the remote head still matches the - * SHA the caller observed. This prevents racing late pushes from another - * worker triggering a merge of a different commit set. - */ -export async function enableAutoMergeOnPr(opts: { - prUrl: string - expectedHeadSha?: string - cwd?: string -}): Promise { + // Best-effort: enable auto-merge (squash) on the freshly created PR. If the + // repo doesn't have "Allow auto-merge" turned on, or the token lacks scope, + // gh exits non-zero and we just log. The PR is still valid; auto-merge can + // be turned on manually. We do NOT fail the whole createPullRequest call — + // the URL was successfully obtained which is the contract this returns. try { - const args = ['pr', 'merge', '--auto', '--squash'] - if (opts.expectedHeadSha) args.push('--match-head-commit', opts.expectedHeadSha) - args.push(opts.prUrl) - await exec('gh', args, opts.cwd ? { cwd: opts.cwd } : {}) - return { ok: true } + await exec('gh', ['pr', 'merge', '--auto', '--squash', url], { cwd: worktreePath }) } catch (err) { const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? '' - return { ok: false, reason: classifyAutoMergeError(stderr), stderr: stderr.slice(0, 500) } + console.warn( + `[createPullRequest] auto-merge enable failed for ${url}: ${stderr.slice(0, 200)}`, + ) } -} -// Zet een draft-PR over naar "ready for review". Gebruikt bij sprint-mode -// wanneer alle stories in de SprintRun DONE zijn — mens reviewt en mergt zelf. -export async function markPullRequestReady(opts: { - prUrl: string - cwd?: string -}): Promise<{ ok: true } | { error: string }> { - try { - await exec('gh', ['pr', 'ready', opts.prUrl], opts.cwd ? { cwd: opts.cwd } : {}) - return { ok: true } - } catch (err) { - const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? '' - // gh-CLI fout "Pull request is not in draft state" is benign wanneer de - // PR al ready was (bv. handmatig ready gezet of een tweede call). - if (/not in draft state|already in ready/i.test(msg)) return { ok: true } - return { error: `gh pr ready failed: ${msg.slice(0, 300)}` } - } + return { url } } export type PrState = 'OPEN' | 'MERGED' | 'CLOSED' @@ -208,7 +139,8 @@ export async function createRevertPullRequest(opts: { pbiCode, } = opts - const worktreeDir = getWorktreeRoot() + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees') const wtPath = path.join(worktreeDir, `revert-${jobId}`) const revertBranch = `revert/${originalBranch}-${jobId.slice(-8)}` diff --git a/src/git/product-worktree.ts b/src/git/product-worktree.ts deleted file mode 100644 index ef0ba15..0000000 --- a/src/git/product-worktree.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { getProductWorktreePath } from './worktree-paths.js' - -const exec = promisify(execFile) - -export async function getOrCreateProductWorktree(opts: { - repoRoot: string - productId: string -}): Promise<{ worktreePath: string; created: boolean }> { - const worktreePath = getProductWorktreePath(opts.productId) - await fs.mkdir(path.dirname(worktreePath), { recursive: true }) - - try { - await fs.access(worktreePath) - return { worktreePath, created: false } - } catch { - // Path bestaat niet — aanmaken - } - - await exec('git', ['fetch', 'origin', '--prune'], { cwd: opts.repoRoot }) - await exec('git', ['worktree', 'add', '--detach', worktreePath, 'origin/main'], { - cwd: opts.repoRoot, - }) - - // Resolve REAL exclude-pad (linked worktree heeft .git als file, niet directory) - const { stdout } = await exec('git', ['rev-parse', '--git-path', 'info/exclude'], { - cwd: worktreePath, - }) - const excludePath = path.resolve(worktreePath, stdout.trim()) - const existing = await fs.readFile(excludePath, 'utf8').catch(() => '') - if (!existing.split('\n').includes('.scratch/')) { - const sep = existing === '' || existing.endsWith('\n') ? '' : '\n' - await fs.appendFile(excludePath, `${sep}.scratch/\n`) - } - - return { worktreePath, created: true } -} - -export async function syncProductWorktree(opts: { worktreePath: string }): Promise { - const { worktreePath } = opts - await exec('git', ['fetch', 'origin', '--prune'], { cwd: worktreePath }) - await exec('git', ['reset', '--hard', 'origin/main'], { cwd: worktreePath }) - await exec('git', ['clean', '-fd', '-e', '.scratch/'], { cwd: worktreePath }) - // Wis .scratch/ inhoud, behoud de map - const scratch = path.join(worktreePath, '.scratch') - await fs.rm(scratch, { recursive: true, force: true }) - await fs.mkdir(scratch, { recursive: true }) -} - -export async function removeProductWorktree(opts: { - repoRoot: string - productId: string -}): Promise<{ removed: boolean }> { - const worktreePath = getProductWorktreePath(opts.productId) - try { - await exec('git', ['worktree', 'remove', '--force', worktreePath], { - cwd: opts.repoRoot, - }) - return { removed: true } - } catch { - return { removed: false } - } -} diff --git a/src/git/worktree-paths.ts b/src/git/worktree-paths.ts deleted file mode 100644 index 4841fd9..0000000 --- a/src/git/worktree-paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as os from 'node:os' -import * as path from 'node:path' - -export const SYSTEM_WORKTREE_DIRS = new Set(['_products']) - -export function getWorktreeRoot(): string { - return ( - process.env.SCRUM4ME_AGENT_WORKTREE_DIR - ?? path.join(os.homedir(), '.scrum4me-agent-worktrees') - ) -} - -export function getProductWorktreePath(productId: string): string { - return path.join(getWorktreeRoot(), '_products', productId) -} - -export function getProductWorktreeLockPath(productId: string): string { - return path.join(getWorktreeRoot(), '_products', `${productId}.lock`) -} diff --git a/src/git/worktree.ts b/src/git/worktree.ts index a27aca6..0c78a24 100644 --- a/src/git/worktree.ts +++ b/src/git/worktree.ts @@ -1,8 +1,8 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import * as path from 'node:path' +import * as os from 'node:os' import * as fs from 'node:fs/promises' -import { getWorktreeRoot } from './worktree-paths.js' const exec = promisify(execFile) @@ -15,19 +15,6 @@ async function branchExists(repoRoot: string, name: string): Promise { } } -async function remoteBranchExists(repoRoot: string, name: string): Promise { - try { - await exec( - 'git', - ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`], - { cwd: repoRoot }, - ) - return true - } catch { - return false - } -} - async function findWorktreeForBranch( repoRoot: string, branchName: string, @@ -63,7 +50,9 @@ export async function createWorktreeForJob(opts: { const { repoRoot, jobId, baseRef = 'origin/main', reuseBranch = false } = opts let { branchName } = opts - const parent = getWorktreeRoot() + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') await fs.mkdir(parent, { recursive: true }) @@ -88,27 +77,7 @@ export async function createWorktreeForJob(opts: { if (occupant) { await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot }) } - // reuseBranch is decided sprint-wide, but git branches are per-repo. For a - // cross-repo sprint the first job targeting THIS repo gets reuseBranch=true - // even though the branch was never created here; a container recreate also - // wipes the local clone. Fall back gracefully instead of failing with - // "invalid reference": - // - local branch exists → reuse it - // - exists on origin only → recreate the local branch tracking origin - // - nowhere → create it fresh from baseRef - if (await branchExists(repoRoot, branchName)) { - await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot }) - } else if (await remoteBranchExists(repoRoot, branchName)) { - await exec( - 'git', - ['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`], - { cwd: repoRoot }, - ) - } else { - await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { - cwd: repoRoot, - }) - } + await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot }) return { worktreePath, branchName } } @@ -152,7 +121,9 @@ export async function removeWorktreeForJob(opts: { }): Promise<{ removed: boolean }> { const { repoRoot, jobId, keepBranch = false } = opts - const parent = getWorktreeRoot() + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') const worktreePath = path.join(parent, jobId) diff --git a/src/index.ts b/src/index.ts index 03f08d8..d05900c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,6 @@ import { registerLogCommitTool } from './tools/log-commit.js' import { registerCreatePbiTool } from './tools/create-pbi.js' import { registerCreateStoryTool } from './tools/create-story.js' import { registerCreateTaskTool } from './tools/create-task.js' -import { registerCreateSprintTool } from './tools/create-sprint.js' -import { registerUpdateSprintTool } from './tools/update-sprint.js' import { registerAskUserQuestionTool } from './tools/ask-user-question.js' import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js' import { registerListOpenQuestionsTool } from './tools/list-open-questions.js' @@ -28,14 +26,9 @@ import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js' import { registerGetIdeaContextTool } from './tools/get-idea-context.js' import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js' import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js' -import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js' import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js' import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js' -// PBI-50: SPRINT_IMPLEMENTATION-tools -import { registerVerifySprintTaskTool } from './tools/verify-sprint-task.js' -import { registerUpdateTaskExecutionTool } from './tools/update-task-execution.js' -import { registerJobHeartbeatTool } from './tools/job-heartbeat.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' import { getAuth } from './auth.js' import { registerWorker } from './presence/worker.js' @@ -80,9 +73,6 @@ async function main() { registerCreatePbiTool(server) registerCreateStoryTool(server) registerCreateTaskTool(server) - // PBI-12: sprint lifecycle tools - registerCreateSprintTool(server) - registerUpdateSprintTool(server) registerAskUserQuestionTool(server) registerGetQuestionAnswerTool(server) registerListOpenQuestionsTool(server) @@ -98,15 +88,10 @@ async function main() { registerGetIdeaContextTool(server) registerUpdateIdeaGrillMdTool(server) registerUpdateIdeaPlanMdTool(server) - registerUpdateIdeaPlanReviewedTool(server) registerLogIdeaDecisionTool(server) // M13: worker quota-gate tools registerGetWorkerSettingsTool(server) registerWorkerHeartbeatTool(server) - // PBI-50: SPRINT_IMPLEMENTATION-tools - registerVerifySprintTaskTool(server) - registerUpdateTaskExecutionTool(server) - registerJobHeartbeatTool(server) registerImplementNextStoryPrompt(server) // Presence bootstrap MUST run before server.connect — the stdio transport diff --git a/src/lib/idea-prompts.ts b/src/lib/idea-prompts.ts new file mode 100644 index 0000000..bcc8873 --- /dev/null +++ b/src/lib/idea-prompts.ts @@ -0,0 +1,32 @@ +// Loader voor embedded idea-prompts (M12). +// De .md-bestanden in src/prompts/idea/ zijn een kopie van +// scrum4me/lib/idea-prompts/* — bewust dupliceren voor reproduceerbaarheid +// op elke worker (geen externe anthropic-skills-plugin-dependency). + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { ClaudeJobKind } from '@prisma/client' + +let cached: { grill?: string; makePlan?: string } = {} + +function loadPrompt(file: 'grill.md' | 'make-plan.md'): string { + const here = dirname(fileURLToPath(import.meta.url)) + // src/lib/idea-prompts.ts → src/lib → src → src/prompts/idea/{file} + const path = join(here, '..', 'prompts', 'idea', file) + return readFileSync(path, 'utf8') +} + +export function getIdeaPromptText(kind: ClaudeJobKind): string { + if (kind === 'IDEA_GRILL') { + if (!cached.grill) cached.grill = loadPrompt('grill.md') + return cached.grill + } + if (kind === 'IDEA_MAKE_PLAN') { + if (!cached.makePlan) cached.makePlan = loadPrompt('make-plan.md') + return cached.makePlan + } + // TASK_IMPLEMENTATION en future kinds: geen embedded prompt nodig. + return '' +} diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts deleted file mode 100644 index ef7270d..0000000 --- a/src/lib/job-config.ts +++ /dev/null @@ -1,207 +0,0 @@ -// PBI-67: model + mode-selectie per ClaudeJob-kind. -// -// Sync met Scrum4Me/lib/job-config.ts — als je hier een veld aanpast, -// doe hetzelfde aan de webapp-kant. Bewust duplicate (geen gedeeld -// package) om de MCP-server eigenstandig te houden. -// -// Override-cascade (eerste match wint): -// 1. task.requires_opus === true → forceer Opus -// 2. job.requested_* (snapshot bij enqueue) -// 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' - | 'claude-sonnet-4-6' - | 'claude-haiku-4-5-20251001' - -export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' - -export type JobConfig = { - model: ClaudeModel - thinking_budget: number // 0 = uit - permission_mode: PermissionMode - max_turns: number | null // null = onbegrensd - allowed_tools: string[] | null // null = alle -} - -export type JobInput = { - kind: string - requested_model?: string | null - requested_thinking_budget?: number | null - requested_permission_mode?: string | null -} - -export type ProductInput = { - preferred_model?: string | null - thinking_budget_default?: number | null - preferred_permission_mode?: string | null -} - -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-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`): - // `plan`-mode wacht op human-approval na elke planning-fase, wat in een - // autonome runner-context betekent dat Claude geen `update_job_status` - // aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst - // doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc). - IDEA_GRILL: { - model: 'claude-sonnet-4-6', - thinking_budget: 12000, - permission_mode: 'acceptEdits', - max_turns: 15, - 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: 'acceptEdits', - max_turns: 20, - allowed_tools: [ - 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', - 'mcp__scrum4me__update_idea_plan_md', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - ], - }, - IDEA_REVIEW_PLAN: { - model: 'claude-opus-4-7', - thinking_budget: 6000, - permission_mode: 'acceptEdits', - max_turns: 1, - allowed_tools: [ - 'Read', 'Write', 'Grep', 'Glob', - 'mcp__scrum4me__update_idea_plan_reviewed', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - 'mcp__scrum4me__ask_user_question', - ], - }, - PLAN_CHAT: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'acceptEdits', - max_turns: 5, - allowed_tools: [ - 'Read', 'Grep', 'AskUserQuestion', - 'mcp__scrum4me__update_job_status', - ], - }, - TASK_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 50, - allowed_tools: TASK_TOOLS, - }, - SPRINT_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 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', - ], - }, -} - -const FALLBACK: JobConfig = { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'default', - max_turns: 50, - allowed_tools: null, -} - -export function getKindDefault(kind: string): JobConfig { - return KIND_DEFAULTS[kind] ?? FALLBACK -} - -// max_turns en allowed_tools blijven kind-default (geen product/task override -// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task). -export function resolveJobConfig( - job: JobInput, - product: ProductInput, - task?: TaskInput, -): JobConfig { - const base = getKindDefault(job.kind) - - const model = ( - task?.requires_opus - ? 'claude-opus-4-7' - : job.requested_model ?? product.preferred_model ?? base.model - ) as ClaudeModel - - const thinking_budget = - job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget - - const permission_mode = (job.requested_permission_mode ?? - product.preferred_permission_mode ?? - base.permission_mode) as PermissionMode - - return { - model, - thinking_budget, - permission_mode, - max_turns: base.max_turns, - allowed_tools: base.allowed_tools, - } -} - -// 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/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' -} diff --git a/src/lib/kind-prompts.ts b/src/lib/kind-prompts.ts deleted file mode 100644 index 15a7a16..0000000 --- a/src/lib/kind-prompts.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Loader voor embedded prompts per ClaudeJob-kind. -// -// De .md-bestanden in src/prompts// worden bewust meegebakken zodat -// elke runner ze kan inlezen zonder externe plugin-dependency. De runner -// (scrum4me-docker/bin/run-one-job.ts) leest de juiste prompt via -// getKindPromptText() en geeft die door als `claude -p`-prompt. -// -// Variabele-vervanging gebeurt door de runner zelf (bv. $PAYLOAD_PATH). - -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -import type { ClaudeJobKind } from '@prisma/client' - -const cache: Partial> = {} - -function loadPrompt(rel: string): string { - const here = dirname(fileURLToPath(import.meta.url)) - // src/lib/kind-prompts.ts → src/lib → src → src/prompts/ - const path = join(here, '..', 'prompts', rel) - return readFileSync(path, 'utf8') -} - -const KIND_TO_PROMPT_PATH: Partial> = { - IDEA_GRILL: 'idea/grill.md', - IDEA_MAKE_PLAN: 'idea/make-plan.md', - IDEA_REVIEW_PLAN: 'idea/review-plan.md', - TASK_IMPLEMENTATION: 'task/implementation.md', - SPRINT_IMPLEMENTATION: 'sprint/implementation.md', - PLAN_CHAT: 'plan-chat/chat.md', -} - -export function getKindPromptText(kind: ClaudeJobKind): string { - if (cache[kind]) return cache[kind]! - const rel = KIND_TO_PROMPT_PATH[kind] - if (!rel) return '' - const text = loadPrompt(rel) - cache[kind] = text - return text -} - -// Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor -// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven -// te wijzigen tot een aparte cleanup-pass. -export function getIdeaPromptText(kind: ClaudeJobKind): string { - if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return '' - return getKindPromptText(kind) -} diff --git a/src/lib/push-trigger.ts b/src/lib/push-trigger.ts deleted file mode 100644 index fb0434a..0000000 --- a/src/lib/push-trigger.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type PushPayload = { title: string; body: string; url: string; tag?: string }; - -export async function triggerPush(userId: string, payload: PushPayload): Promise { - const url = process.env.INTERNAL_PUSH_URL; - const secret = process.env.INTERNAL_PUSH_SECRET; - if (!url || !secret) return; // feature-gated - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, - body: JSON.stringify({ userId, payload }), - signal: controller.signal, - }); - if (!res.ok) console.warn('[push-trigger] non-2xx', res.status); - } catch (err) { - console.error('[push-trigger]', err); - } finally { - clearTimeout(timeout); - } -} diff --git a/src/lib/tasks-status-update.ts b/src/lib/tasks-status-update.ts index 6dde6c5..3549f3d 100644 --- a/src/lib/tasks-status-update.ts +++ b/src/lib/tasks-status-update.ts @@ -38,11 +38,6 @@ export async function propagateStatusUpwards( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, - // PBI-50: optionele expliciete sprint_run_id voor SPRINT_IMPLEMENTATION - // (waar geen ClaudeJob.task_id-koppeling bestaat). Wanneer afwezig valt - // de helper terug op de lookup via ClaudeJob.task_id, met als laatste - // fallback Story → Sprint → SprintRun.findFirst({ status: active }). - sprintRunId?: string, ): Promise { const run = async (tx: Prisma.TransactionClient): Promise => { const task = await tx.task.update({ @@ -140,15 +135,15 @@ export async function propagateStatusUpwards( let nextStatus: SprintStatus if (anyPbiFailed) nextStatus = 'FAILED' - else if (allPbisDone) nextStatus = 'CLOSED' - else nextStatus = 'OPEN' + else if (allPbisDone) nextStatus = 'COMPLETED' + else nextStatus = 'ACTIVE' if (nextStatus !== sprint.status) { await tx.sprint.update({ where: { id: sprint.id }, data: { status: nextStatus, - ...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}), + ...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}), }, }) sprintChanged = true @@ -156,43 +151,18 @@ export async function propagateStatusUpwards( } } - // SprintRun herevalueren. Resolve sprint_run_id in volgorde: - // 1. Expliciete sprintRunId-arg (PBI-50: SPRINT_IMPLEMENTATION-pad). - // 2. ClaudeJob.task_id-lookup (PER_TASK-flow). - // 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen - // task-job, bv. handmatige task-statuswijziging via UI). + // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task let sprintRunChanged = false - if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') { - let resolvedRunId: string | null = sprintRunId ?? null - let cancelExceptJobId: string | null = null + if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') { + const job = await tx.claudeJob.findFirst({ + where: { task_id: taskId, sprint_run_id: { not: null } }, + orderBy: { created_at: 'desc' }, + select: { id: true, sprint_run_id: true }, + }) - if (!resolvedRunId) { - const job = await tx.claudeJob.findFirst({ - where: { task_id: taskId, sprint_run_id: { not: null } }, - orderBy: { created_at: 'desc' }, - select: { id: true, sprint_run_id: true }, - }) - if (job?.sprint_run_id) { - resolvedRunId = job.sprint_run_id - cancelExceptJobId = job.id - } - } - - if (!resolvedRunId && story.sprint_id) { - const activeRun = await tx.sprintRun.findFirst({ - where: { - sprint_id: story.sprint_id, - status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, - }, - orderBy: { created_at: 'desc' }, - select: { id: true }, - }) - if (activeRun) resolvedRunId = activeRun.id - } - - if (resolvedRunId) { + if (job?.sprint_run_id) { const sprintRun = await tx.sprintRun.findUnique({ - where: { id: resolvedRunId }, + where: { id: job.sprint_run_id }, select: { id: true, status: true }, }) if ( @@ -210,16 +180,11 @@ export async function propagateStatusUpwards( failed_task_id: taskId, }, }) - // Cancel sibling-jobs binnen dezelfde SprintRun behalve de - // huidige task-job (als die er is). Voor SPRINT_IMPLEMENTATION - // is cancelExceptJobId null en hebben we geen siblings om te - // cancellen — de SPRINT-job zelf blijft actief en de worker - // detecteert dit via job_heartbeat. await tx.claudeJob.updateMany({ where: { sprint_run_id: sprintRun.id, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - ...(cancelExceptJobId ? { id: { not: cancelExceptJobId } } : {}), + id: { not: job.id }, }, data: { status: 'CANCELLED', @@ -265,16 +230,14 @@ export interface UpdateTaskStatusResult { task: PropagationResult['task'] storyStatusChange: StoryStatusChange storyId: string - sprintRunChanged: boolean } export async function updateTaskStatusWithStoryPromotion( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, - sprintRunId?: string, ): Promise { - const result = await propagateStatusUpwards(taskId, newStatus, client, sprintRunId) + const result = await propagateStatusUpwards(taskId, newStatus, client) let storyStatusChange: StoryStatusChange = null if (result.storyChanged) { storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted' @@ -283,6 +246,5 @@ export async function updateTaskStatusWithStoryPromotion( task: result.task, storyStatusChange, storyId: result.storyId, - sprintRunChanged: result.sprintRunChanged, } } diff --git a/src/prompts/idea/grill.md b/src/prompts/idea/grill.md index 13be8d1..d5af711 100644 --- a/src/prompts/idea/grill.md +++ b/src/prompts/idea/grill.md @@ -1,28 +1,21 @@ # Grill-prompt voor IDEA_GRILL-jobs -> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als -> `claude -p`-input meegegeven voor één geclaimde `IDEA_GRILL`-job. Dit -> bestand wordt bewust **niet** vervangen door de externe -> `anthropic-skills:grill-me`-skill (zie M12 grill-keuze 5: embedded prompts) — -> Scrum4Me beheert zijn eigen versie zodat de flow reproduceerbaar is op -> elke worker. +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt +> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill +> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen +> versie zodat de flow reproduceerbaar is op elke worker. --- -Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job -al voor je geclaimd; jouw eerste actie is altijd: +Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: +`{idea_title}`). -``` -Read $PAYLOAD_PATH -``` +Je context (meegegeven in `wait_for_job`-payload): -Dat JSON-bestand bevat de volledige context die je nodig hebt: - -- `job_id`: nodig voor `update_job_status` aan het einde -- `idea`: het volledige idee-record incl. `id`, `code`, `title`, `description`, - `product_id`, en eventueel bestaande `grill_md` +- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` - `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) -- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al) +- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) ## Doel @@ -32,11 +25,11 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via ## Werkwijze (loop, één vraag per cyclus) -1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`, - `idea.title`, `idea.grill_md` (mag null zijn), `product.id`, en `job_id` — - die heb je nodig in alle MCP-tool-calls hieronder. -2. Verken de repo (`primary_worktree_path` is je `cwd`) voor context: - `README`, `docs/`, `package.json`, relevante source. `Read`/`Grep`/`Glob`. +1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig) + `idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit + het niet weg. +2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante + source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal. 3. Stel **één scherpe vraag tegelijk** via `mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`). @@ -46,8 +39,7 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via 5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie). 6. Schrijf het eindresultaat via `mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`. -7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` - — dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt. +7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. ## Stop-conditie @@ -63,7 +55,7 @@ Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". ## Output-format (strikt) ```markdown -# Idee — +# Idee — {korte titel} ## Scope … diff --git a/src/prompts/idea/make-plan.md b/src/prompts/idea/make-plan.md index 300eaf6..86891a0 100644 --- a/src/prompts/idea/make-plan.md +++ b/src/prompts/idea/make-plan.md @@ -1,29 +1,21 @@ # Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs -> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als -> `claude -p`-input meegegeven voor één geclaimde `IDEA_MAKE_PLAN`-job. -> Single-pass, **stel geen vragen** (zie M12 grill-keuze 8). Twijfels → -> terug naar grill via UI. +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze +> 8). Twijfels → terug naar grill via UI. --- -Je bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de -job al voor je geclaimd; jouw eerste actie is altijd: +Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. -``` -Read $PAYLOAD_PATH -``` +Je context (meegegeven in `wait_for_job`-payload): -Dat JSON-bestand bevat de volledige context die je nodig hebt: - -- `job_id`: nodig voor `update_job_status` aan het einde -- `idea.id`, `idea.code`, `idea.title`, `idea.description` - `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je primaire input. -- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als referentie. +- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als + referentie. - `product`: gekoppeld product met `repo_url`, `definition_of_done`, bestaande architectuur in repo. -- `primary_worktree_path`: lokale repo (je `cwd` zit daar al). ## Doel @@ -34,18 +26,13 @@ PBI + stories + taken via `materializeIdeaPlanAction`. ## Werkwijze (single-pass) -1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`, - `idea.grill_md`, `idea.plan_md` (mag null zijn), `product.id`, en `job_id` — - die heb je nodig in alle MCP-tool-calls hieronder. -2. Lees `idea.grill_md` volledig. -3. Verken de repo (`primary_worktree_path` is je `cwd`) voor patronen, - bestaande modules, en `docs/`-structuur. -4. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende +1. Lees `idea.grill_md` volledig. +2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur. +3. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf. -5. Bouw het plan op in de **strikte format** hieronder. -6. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. -7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` - — dit sluit de job af. **Verplicht**, ook bij parse-failure. +4. Bouw het plan op in de **strikte format** hieronder. +5. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. +6. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. ## Dependency-cascade-grep (verplicht bij removal/refactor) diff --git a/src/prompts/idea/review-plan.md b/src/prompts/idea/review-plan.md deleted file mode 100644 index 8df45f6..0000000 --- a/src/prompts/idea/review-plan.md +++ /dev/null @@ -1,210 +0,0 @@ -# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs - -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie** -> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan -> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`. - ---- - -Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`. - -Je context (meegegeven in `wait_for_job`-payload): - -- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body) -- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's) -- `product`: gekoppeld product met `definition_of_done` en repo-context -- `repo_url`: lokale repo om bestaande patronen/code te raadplegen - -## Doel - -Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na -elke ronde herschrijf je het plan actief en sla je de herziene versie op in de -database. De reviews werken op convergentie af: zodra het plan stabiel is -(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring. - -**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en -gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je -coördineert een actief verbeterproces. - -## Werkwijze - -### Setup (voor ronde 1) - -1. Lees `idea.plan_md` volledig — dit is de startversie van het plan. -2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context. -3. **Laad codex** (verplicht, niet optioneel): - - Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen - - Glob + Read alle `docs/architecture/**/*.md` → systeemdesign - - Read `CLAUDE.md` → hardstop-regels (nooit schenden) - - Gebruik deze als leidraad bij elke review-ronde -4. Initialiseer `review_log`: - ```json - { "plan_file": "{idea_code}", "created_at": "", - "rounds": [], "approval": { "status": "pending" } } - ``` - -### Per Review-Ronde - -**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)** -- Rol: structuur-reviewer — focus op correctheid, niet op inhoud -- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings, - priority-waarden valid (1–4), markdown-structuur intact -- Herschrijf plan_md: corrigeer structuurfouten en formatting -- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar - via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik - -**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)** -- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit -- Controleer: stories volgen uit grill-criteria, tasks zijn concreet - (bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd, - `verify_required` coherent, dependency-cascades geadresseerd -- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe - -**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)** -- Rol: risico-reviewer — focus op wat mis kan gaan -- Controleer: grote taken gesplitst, refactors hebben undo-strategie, - schema-changes hebben migratie-taken, type-checking expliciet, concurrency - geadresseerd, error-handling per actie, feature-flags voor grote changes -- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken - -### Plan Revision (na elke ronde — verplicht) - -Na het uitvoeren van de review-criteria: - -1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`. -2. Herschrijf `plan_md` — integreer de gevonden verbeteringen. -3. Bereken `diff_pct = changed_lines / total_lines * 100`. -4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`. -5. **Persisteer de herziene versie** via: - ``` - update_idea_plan_md({ idea_id: , plan_md: }) - ``` - Dit slaat het verbeterde plan op in de database zodat de gebruiker - de progressie ziet. Sla dit stap niet over — ook al zijn er weinig - wijzigingen. - -### Convergence Detection - -Na elke ronde (m.u.v. ronde 0): -``` -diff_pct_this_round = changed_lines / total_lines * 100 -if diff_pct_this_round < 5 AND prev_round_diff_pct < 5: - → CONVERGED -``` - -Indien converged (of na ronde 2 als max bereikt): -- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }` -- Vraag goedkeuring via `ask_user_question` - -## Review-Criteria per Ronde - -### Ronde 1 — Structuur & Syntax -- [ ] Frontmatter YAML parseable -- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`) -- [ ] Priority-waarden valid (1–4) -- [ ] Geen lege strings in verplichte velden -- [ ] Markdown-structuur correct (headers, code-blocks) - -### Ronde 2 — Logica & Patronen -- [ ] Stories volgen logisch uit grill-acceptance-criteria -- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract) -- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor) -- [ ] Patronen uit `docs/patterns/` worden gevolgd -- [ ] Implementatie-plan per task is actionable -- [ ] `verify_required` waarden coherent met task-scope - -### Ronde 3 — Risico & Edge Cases -- [ ] Grote taken (> 4u) zijn gesplitst in subtaken -- [ ] Refactors hebben een undo/rollback-strategie -- [ ] Schema-changes hebben migratie-taken -- [ ] Type-checking wordt expliciet geverifieerd (einde-taak) -- [ ] Concurrency-issues / race-conditions geadresseerd -- [ ] Error-handling per actie duidelijk -- [ ] Feature-flags ingebouwd voor grote of riskante changes - -## Stappen (uitgebreid algoritme) - -1. **Init** - - Lees plan_md + grill_md. - - Laad codex (docs/patterns, docs/architecture, CLAUDE.md). - - Initialiseer `review_log`. - -2. **Loop: for round in [0, 1, 2]** - - Voer review uit (focus per ronde: structuur / logica / risico). - - Sla `plan_before` op. - - Herschrijf plan_md op basis van bevindingen. - - Roep `update_idea_plan_md` aan met de herziene tekst. - - Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log. - - Check convergence (na ronde 1+). - - Break indien converged. - -3. **Approval Gate** - - Vraag via `ask_user_question`: - "Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?" - - Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]` - - "Ja": `approval.status = 'approved'` → ga door naar Save & Close. - - "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen). - - "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen. - -4. **Save & Close** - - Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`. - - Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`. - -## Output-format review_log (strikt JSON) - -```json -{ - "plan_file": "IDEA-016", - "created_at": "ISO8601", - "rounds": [ - { - "round": 0, - "model": "claude-opus-4-7", - "role": "Structure Review", - "focus": "YAML parsing, format, syntax", - "plan_before": "", - "plan_after": "", - "issues": [ - { - "category": "structure|logic|risk|pattern", - "severity": "error|warning|info", - "suggestion": "wat te fixen" - } - ], - "score": 75, - "plan_diff_lines": 12, - "converged": false, - "timestamp": "ISO8601" - } - ], - "convergence": { - "stable_at_round": 2, - "final_diff_pct": 2.1, - "convergence_metric": "plan_stability" - }, - "approval": { - "status": "pending|approved|rejected", - "timestamp": "ISO8601" - }, - "summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status" -} -``` - -## Foutgevallen - -- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop. -- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal. -- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet. -- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`. - -## Aannames & Limieten - -- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige - job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model. - De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden. - Toekomst: directe model-switching via Anthropic API. -- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB). -- Repo is leesbaar; geen network-fouts verwacht. -- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal). -- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`). diff --git a/src/prompts/plan-chat/chat.md b/src/prompts/plan-chat/chat.md deleted file mode 100644 index 224d51e..0000000 --- a/src/prompts/plan-chat/chat.md +++ /dev/null @@ -1,16 +0,0 @@ -# PLAN_CHAT-prompt (placeholder) - -> Deze prompt is een placeholder. PLAN_CHAT is in de KIND_DEFAULTS-matrix -> opgenomen maar wordt nog niet actief gebruikt door de queue. Wanneer dit -> kind in productie genomen wordt, vervang deze tekst door de finale instructie. - ---- - -Je bent gestart voor een `PLAN_CHAT`-job. De payload staat in: - -``` -$PAYLOAD_PATH -``` - -Lees de payload en doe wat erin staat. Sluit af met -`mcp__scrum4me__update_job_status({ job_id, status: 'done' })`. diff --git a/src/prompts/sprint/implementation.md b/src/prompts/sprint/implementation.md deleted file mode 100644 index 9089f8a..0000000 --- a/src/prompts/sprint/implementation.md +++ /dev/null @@ -1,77 +0,0 @@ -# SPRINT_IMPLEMENTATION-prompt - -> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input -> meegegeven voor één geclaimde `SPRINT_IMPLEMENTATION`-job. Eén job = de hele -> sprint-run sequentieel afhandelen. - ---- - -Je bent gestart voor één geclaimde `SPRINT_IMPLEMENTATION`-job. De payload bevat -een **frozen scope-snapshot** met alle te verwerken taken: - -``` -$PAYLOAD_PATH -``` - -Lees die payload eerst. Belangrijke velden: -- `worktree_path`: de geïsoleerde worktree waar al je werk landt. -- `branch_name`: de feature-branch (bv. `feat/sprint-`); bij PR-strategy - SPRINT zit alle werk in één branch. -- `task_executions[]`: ordered lijst van `SprintTaskExecution`-rijen. Verwerk in - `order`-volgorde. Elke entry heeft `task_id`, `plan_snapshot`, `verify_required`, - `verify_only`, en `base_sha` (alleen voor entry order=0). -- `pbis[]`, `stories[]`: context voor begrip; geen wijzigingen daarop. -- `sprint_run.id`: nodig voor `update_task_status` cascade-prop. Geef altijd - `sprint_run_id` mee aan `update_task_status`. - -## Hard regels - -- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft geclaimd. -- **GEEN** `mcp__scrum4me__job_heartbeat` aanroepen. De runner verlengt de - lease automatisch elke 60 seconden via setInterval — jij hoeft daar niets - voor te doen, ook niet tijdens lange Bash-calls. -- Werk uitsluitend in `worktree_path` op `branch_name`. Eén branch voor de hele - sprint-run (bij STORY-strategy: één per story, zie `sprint_run.pr_strategy`). -- Verwerk taken in de exacte `order`-volgorde uit `task_executions[]`. - -## Workflow per task_execution - -Voor elke entry in `task_executions[]` (in order-volgorde): - -1. **Start**: `update_task_execution({ execution_id, status: 'RUNNING' })` en - `update_task_status({ task_id, status: 'in_progress', sprint_run_id })`. -2. **Lees** het `plan_snapshot` uit de execution + de bredere context uit - `task`/`story`/`pbi` in de payload. -3. **Implementeer** de taak in `worktree_path`. Commit per logische laag met - `git add -A && git commit`. -4. **Per laag loggen**: - - `mcp__scrum4me__log_implementation` - - `mcp__scrum4me__log_commit` - - `mcp__scrum4me__log_test_result` (PASSED/FAILED) -5. **Verify-gate** (als `verify_required === true`): - `mcp__scrum4me__verify_sprint_task({ execution_id })`. Bij DIVERGENT: stop de - sprint en sluit af met `update_job_status('failed')`. -6. **Afronden taak**: - - Bij ALIGNED/PARTIAL: `update_task_status({ task_id, status: 'done', sprint_run_id })` - en `update_task_execution({ execution_id, status: 'DONE' })`. - - Bij EMPTY (no-op): `update_task_execution({ execution_id, status: 'SKIPPED' })` - en `update_task_status({ task_id, status: 'done', sprint_run_id })`. - -## Sprint afronden - -Na de laatste `task_execution`: - -- **Verify-gate run**: optioneel een algemene `npm run verify` op de hele worktree. -- **Sluit de job af**: `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` - met een samenvatting van wat is afgerond. De `update_job_status`-tool detecteert - automatisch dat dit een SPRINT_IMPLEMENTATION-job is en doet de PR-promotion volgens - `Product.auto_pr` en `sprint_run.pr_strategy`. - -Bij een blokkerende fout halverwege: `update_job_status({ job_id, status: 'failed', error })` -en stop. De runner zorgt voor lease-cleanup. - -## Vragen aan de gebruiker - -Voor blokkerende keuzes: `mcp__scrum4me__ask_user_question` + wacht op antwoord -met `mcp__scrum4me__get_question_answer`. Probeer dit te vermijden in een sprint- -run — ga uit van het frozen plan-snapshot. diff --git a/src/prompts/task/implementation.md b/src/prompts/task/implementation.md deleted file mode 100644 index fa408ee..0000000 --- a/src/prompts/task/implementation.md +++ /dev/null @@ -1,58 +0,0 @@ -# TASK_IMPLEMENTATION-prompt - -> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input -> meegegeven voor één geclaimde `TASK_IMPLEMENTATION`-job. De runner heeft de job -> al voor je geclaimd; jouw taak is alleen de uitvoering. - ---- - -Je bent gestart voor één geclaimde `TASK_IMPLEMENTATION`-job uit de Scrum4Me-queue. -De volledige job-payload (inclusief task, story, pbi, sprint, product, config en -worktree_path) staat in: - -``` -$PAYLOAD_PATH -``` - -Lees die payload eerst met `Read $PAYLOAD_PATH`. Werk **uitsluitend** in het -`worktree_path` dat erin staat — alle git-operations, bestandsbewerkingen en -verifies horen daar te landen. - -## Hard regels - -- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor je - geclaimd. Eén Claude-invocation = één job. -- **GEEN** `mcp__scrum4me__check_queue_empty`. Je sluit af na deze ene job. -- Werk in het toegewezen worktree-pad; geen edits in andere directories. -- Volg `task.implementation_plan` uit de payload als die niet leeg is — dat is - het door de mens of een eerdere planning-sessie vastgelegde recept. - -## Workflow - -1. **Status op in_progress**: `mcp__scrum4me__update_task_status({ task_id, status: 'in_progress' })`. -2. **Plan lezen**: Lees `task.implementation_plan` uit de payload + relevante - project-docs (`docs/specs/functional.md`, eventueel `docs/patterns/*.md`). -3. **Implementeer** de taak: lees → verander → test → commit per logische laag. - Gebruik `git add -A && git commit` per laag, **geen** `git push`. -4. **Logging per laag**: - - `mcp__scrum4me__log_implementation` met een korte beschrijving van wat je - gewijzigd hebt en waarom. - - `mcp__scrum4me__log_commit` met `commit_hash` en `commit_message` na elke - commit (haal hash uit `git rev-parse HEAD`). - - `mcp__scrum4me__log_test_result` met PASSED/FAILED en uitleg na elke - `npm test` of build-run. -5. **Verify-gate**: roep `mcp__scrum4me__verify_task_against_plan({ task_id })` - aan om de wijzigingen tegen het plan te toetsen. -6. **Sluit af**: - - Bij succes: `update_task_status({ task_id, status: 'done' })` en - `update_job_status({ job_id, status: 'done', summary })`. - - Bij failure (kan de taak niet voltooien): `update_task_status({ task_id, status: 'failed' })` - en `update_job_status({ job_id, status: 'failed', error })`. - - Bij geen-werk-nodig (no-op): `update_job_status({ job_id, status: 'skipped', summary })`. - -## Vragen aan de gebruiker - -Als je een blokkerende keuze tegenkomt waarvoor je input nodig hebt, gebruik -`mcp__scrum4me__ask_user_question` en wacht op het antwoord met -`mcp__scrum4me__get_question_answer`. Vraag **niet** voor zaken die je zelf -kunt afleiden uit het plan. diff --git a/src/status.ts b/src/status.ts index dd37dc8..b256252 100644 --- a/src/status.ts +++ b/src/status.ts @@ -6,7 +6,6 @@ const TASK_DB_TO_API = { REVIEW: 'review', DONE: 'done', FAILED: 'failed', - EXCLUDED: 'excluded', } as const satisfies Record const TASK_API_TO_DB: Record = { @@ -15,7 +14,6 @@ const TASK_API_TO_DB: Record = { review: 'REVIEW', done: 'DONE', failed: 'FAILED', - excluded: 'EXCLUDED', } const STORY_DB_TO_API = { diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index 3618b5e..b4d5a59 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -10,7 +10,6 @@ import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessStory, userOwnsIdea } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' -import { triggerPush } from '../lib/push-trigger.js' const PENDING_TTL_HOURS = 24 const POLL_INTERVAL_MS = 2_000 @@ -128,13 +127,6 @@ export function registerAskUserQuestionTool(server: McpServer) { }, }) - void triggerPush(auth.userId, { - title: 'Claude heeft een vraag', - body: question.slice(0, 120), - url: '/notifications', - tag: `claude-q-${created.id}`, - }) - // Async-mode (default): return direct. if (!wait_seconds || wait_seconds === 0) { return toolJson(summarize(created)) diff --git a/src/tools/cleanup-my-worktrees.ts b/src/tools/cleanup-my-worktrees.ts index e23e1aa..bfcc444 100644 --- a/src/tools/cleanup-my-worktrees.ts +++ b/src/tools/cleanup-my-worktrees.ts @@ -1,11 +1,12 @@ import { z } from 'zod' import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { toolJson, withToolErrors } from '../errors.js' import { removeWorktreeForJob } from '../git/worktree.js' -import { getWorktreeRoot, SYSTEM_WORKTREE_DIRS } from '../git/worktree-paths.js' import { resolveRepoRoot } from './wait-for-job.js' const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED']) @@ -14,20 +15,16 @@ const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING']) const inputSchema = z.object({}) export async function getWorktreeParent(): Promise { - return getWorktreeRoot() + return ( + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + ) } export async function listWorktreeJobIds(worktreeParent: string): Promise { try { const entries = await fs.readdir(worktreeParent, { withFileTypes: true }) - return entries - .filter( - (e) => - e.isDirectory() - && !SYSTEM_WORKTREE_DIRS.has(e.name) - && !e.name.endsWith('.lock'), - ) - .map((e) => e.name) + return entries.filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] } diff --git a/src/tools/create-sprint.ts b/src/tools/create-sprint.ts deleted file mode 100644 index 5d8cd9b..0000000 --- a/src/tools/create-sprint.ts +++ /dev/null @@ -1,113 +0,0 @@ -// MCP authoring tool: create een Sprint binnen een product. -// -// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints -// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd -// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition -// op de unique constraint (@@unique([product_id, code])). - -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { Prisma } from '@prisma/client' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { userCanAccessProduct } from '../access.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -const SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/ -const MAX_CODE_ATTEMPTS = 3 - -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10) -} - -async function generateNextSprintCode(productId: string): Promise { - const today = todayIsoDate() - const sprints = await prisma.sprint.findMany({ - where: { product_id: productId, code: { startsWith: `S-${today}-` } }, - select: { code: true }, - }) - let max = 0 - for (const s of sprints) { - const m = s.code?.match(SPRINT_AUTO_RE) - // Dubbele check op de datum — defensive tegen filterveranderingen - // of mock-data die niet door de DB-where heen ging. - if (m && m[1] === today) { - const n = Number.parseInt(m[2], 10) - if (!Number.isNaN(n) && n > max) max = n - } - } - return `S-${today}-${max + 1}` -} - -function isCodeUniqueConflict(error: unknown): boolean { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false - if (error.code !== 'P2002') return false - const target = (error.meta as { target?: string[] | string } | undefined)?.target - if (!target) return false - return Array.isArray(target) ? target.includes('code') : target.includes('code') -} - -export const inputSchema = z.object({ - product_id: z.string().min(1), - code: z.string().min(1).max(30).optional(), - sprint_goal: z.string().min(1).max(500), - start_date: z.string().date().optional(), -}) - -export async function handleCreateSprint( - { product_id, code, sprint_goal, start_date }: z.infer, -) { - return withToolErrors(async () => { - const auth = await requireWriteAccess() - if (!(await userCanAccessProduct(product_id, auth.userId))) { - return toolError(`Product ${product_id} not found or not accessible`) - } - - const resolvedStartDate = start_date ? new Date(start_date) : new Date() - const baseSelect = { - id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - created_at: true, - } as const - - if (code) { - const sprint = await prisma.sprint.create({ - data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate }, - select: baseSelect, - }) - return toolJson(sprint) - } - - let lastError: unknown - for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { - const generated = await generateNextSprintCode(product_id) - try { - const sprint = await prisma.sprint.create({ - data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate }, - select: baseSelect, - }) - return toolJson(sprint) - } catch (e) { - if (isCodeUniqueConflict(e)) { lastError = e; continue } - throw e - } - } - throw lastError ?? new Error('Kon geen unieke sprint-code genereren') - }) -} - -export function registerCreateSprintTool(server: McpServer) { - server.registerTool( - 'create_sprint', - { - title: 'Create Sprint', - description: - 'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.', - inputSchema, - }, - handleCreateSprint, - ) -} diff --git a/src/tools/create-story.ts b/src/tools/create-story.ts index 37caa59..cfa099e 100644 --- a/src/tools/create-story.ts +++ b/src/tools/create-story.ts @@ -1,9 +1,8 @@ // MCP authoring tool: create een Story onder een bestaande PBI. // // product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md -// convention — nooit vertrouwen op client-input). Zonder sprint_id is -// status='OPEN' en landt de story in de Product Backlog; mét sprint_id -// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT'). +// convention — nooit vertrouwen op client-input). status='OPEN' default; +// landt in de Product Backlog, niet auto in een sprint. import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -47,108 +46,75 @@ const inputSchema = z.object({ acceptance_criteria: z.string().max(4000).optional(), priority: z.number().int().min(1).max(4), sort_order: z.number().optional(), - // Optionele sprint-koppeling: bij creatie de story direct aan een sprint - // hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen. - sprint_id: z.string().min(1).optional(), }) -export async function handleCreateStory( - { - pbi_id, - title, - description, - acceptance_criteria, - priority, - sort_order, - sprint_id, - }: z.infer, -) { - return withToolErrors(async () => { - const auth = await requireWriteAccess() - - const pbi = await prisma.pbi.findUnique({ - where: { id: pbi_id }, - select: { product_id: true }, - }) - if (!pbi) return toolError(`PBI ${pbi_id} not found`) - if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { - return toolError(`PBI ${pbi_id} not accessible`) - } - - // Optionele sprint-koppeling: valideer dat de sprint bestaat én bij - // hetzelfde product hoort — voorkomt een cross-product koppeling. - if (sprint_id !== undefined) { - const sprint = await prisma.sprint.findUnique({ - where: { id: sprint_id }, - select: { product_id: true }, - }) - if (!sprint) return toolError(`Sprint ${sprint_id} not found`) - if (sprint.product_id !== pbi.product_id) { - return toolError( - `Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`, - ) - } - } - - let resolvedSortOrder = sort_order - if (resolvedSortOrder === undefined) { - const last = await prisma.story.findFirst({ - where: { pbi_id, priority }, - orderBy: { sort_order: 'desc' }, - select: { sort_order: true }, - }) - resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 - } - - let lastError: unknown - for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { - const code = await generateNextStoryCode(pbi.product_id) - try { - const story = await prisma.story.create({ - data: { - pbi_id, - product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input - sprint_id: sprint_id ?? null, - code, - title, - description: description ?? null, - acceptance_criteria: acceptance_criteria ?? null, - priority, - sort_order: resolvedSortOrder, - status: sprint_id ? 'IN_SPRINT' : 'OPEN', - }, - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - sprint_id: true, - created_at: true, - }, - }) - return toolJson(story) - } catch (e) { - if (isCodeUniqueConflict(e)) { lastError = e; continue } - throw e - } - } - throw lastError ?? new Error('Kon geen unieke Story-code genereren') - }) -} - export function registerCreateStoryTool(server: McpServer) { server.registerTool( 'create_story', { title: 'Create story', description: - 'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', + 'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', inputSchema, }, - handleCreateStory, + async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + + const pbi = await prisma.pbi.findUnique({ + where: { id: pbi_id }, + select: { product_id: true }, + }) + if (!pbi) return toolError(`PBI ${pbi_id} not found`) + if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { + return toolError(`PBI ${pbi_id} not accessible`) + } + + let resolvedSortOrder = sort_order + if (resolvedSortOrder === undefined) { + const last = await prisma.story.findFirst({ + where: { pbi_id, priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 + } + + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextStoryCode(pbi.product_id) + try { + const story = await prisma.story.create({ + data: { + pbi_id, + product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input + code, + title, + description: description ?? null, + acceptance_criteria: acceptance_criteria ?? null, + priority, + sort_order: resolvedSortOrder, + status: 'OPEN', + }, + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + created_at: true, + }, + }) + return toolJson(story) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke Story-code genereren') + }), ) } diff --git a/src/tools/get-claude-context.ts b/src/tools/get-claude-context.ts index 80f7a4c..fb450e7 100644 --- a/src/tools/get-claude-context.ts +++ b/src/tools/get-claude-context.ts @@ -47,7 +47,7 @@ export function registerGetClaudeContextTool(server: McpServer) { } const activeSprint = await prisma.sprint.findFirst({ - where: { product_id, status: 'OPEN' }, + where: { product_id, status: 'ACTIVE' }, orderBy: { created_at: 'desc' }, select: { id: true, sprint_goal: true, status: true }, }) diff --git a/src/tools/job-heartbeat.ts b/src/tools/job-heartbeat.ts deleted file mode 100644 index 36c42a2..0000000 --- a/src/tools/job-heartbeat.ts +++ /dev/null @@ -1,81 +0,0 @@ -// PBI-50 F3-T3: job_heartbeat -// -// Verlengt ClaudeJob.lease_until met 5 min zodat resetStaleClaimedJobs een -// long-running job (bv. SPRINT_IMPLEMENTATION over 30+ min) niet ten onrechte -// als stale markt. Worker draait een achtergrond-loop elke 60s. -// -// Voor SPRINT-jobs: respons bevat sprint_run_status zodat de worker zijn -// loop kan breken bij ≠ RUNNING (bv. UI-side cancel of MERGE_CONFLICT-pause). - -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -const inputSchema = z.object({ - job_id: z.string().min(1), -}) - -export function registerJobHeartbeatTool(server: McpServer) { - server.registerTool( - 'job_heartbeat', - { - title: 'Job heartbeat', - description: - 'Extend the lease on a CLAIMED/RUNNING job by 5 minutes. Token must own the job. ' + - 'For SPRINT_IMPLEMENTATION jobs: response includes sprint_run_status so the worker ' + - 'can break its task-loop on UI-side cancel/pause without an extra query. ' + - 'Worker should call this every ~60s during long-running batches. ' + - 'Forbidden for demo accounts.', - inputSchema, - }, - async ({ job_id }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - - // Atomic conditional UPDATE so a non-owner / non-active job is rejected - // without a separate read. - const updated = await prisma.$queryRaw< - Array<{ id: string; lease_until: Date; kind: string; sprint_run_id: string | null }> - >` - UPDATE claude_jobs - SET lease_until = NOW() + INTERVAL '5 minutes' - WHERE id = ${job_id} - AND claimed_by_token_id = ${auth.tokenId} - AND status IN ('CLAIMED', 'RUNNING') - RETURNING id, lease_until, kind::text AS kind, sprint_run_id - ` - if (updated.length === 0) { - return toolError( - `Job ${job_id} not found, not claimed by your token, or in terminal state`, - ) - } - const row = updated[0] - - let sprint_run_status: string | null = null - let sprint_run_pause_reason: string | null = null - if (row.kind === 'SPRINT_IMPLEMENTATION' && row.sprint_run_id) { - const sprintRun = await prisma.sprintRun.findUnique({ - where: { id: row.sprint_run_id }, - select: { status: true, pause_context: true }, - }) - sprint_run_status = sprintRun?.status ?? null - // Extract pause_reason from pause_context Json (best-effort) - const ctx = sprintRun?.pause_context as - | { pause_reason?: string } - | null - | undefined - sprint_run_pause_reason = ctx?.pause_reason ?? null - } - - return toolJson({ - ok: true, - job_id: row.id, - lease_until: row.lease_until.toISOString(), - sprint_run_status, - sprint_run_pause_reason, - }) - }), - ) -} diff --git a/src/tools/update-idea-plan-reviewed.ts b/src/tools/update-idea-plan-reviewed.ts deleted file mode 100644 index 2e9f1ac..0000000 --- a/src/tools/update-idea-plan-reviewed.ts +++ /dev/null @@ -1,126 +0,0 @@ -// MCP-tool: writes the review-log result after an IDEA_REVIEW_PLAN job and -// transitions idea.status. Only an explicit approval_status='approved' moves -// the idea to PLAN_REVIEWED; anything else (rejected, pending, or omitted) -// goes to PLAN_REVIEW_FAILED — a human must then decide. The tool never -// silently approves. -// -// Called by the worker as the final step of a review-plan session. - -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' - -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { userOwnsIdea } from '../access.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -export const inputSchema = z.object({ - idea_id: z.string().min(1), - review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object) - approval_status: z - .enum(['pending', 'approved', 'rejected'] as const) - .optional(), -}) - -export async function handleUpdateIdeaPlanReviewed( - { idea_id, review_log, approval_status }: z.infer, -) { - return withToolErrors(async () => { - const auth = await requireWriteAccess() - if (!(await userOwnsIdea(idea_id, auth.userId))) { - return toolError('Idea not found') - } - - // Alleen een expliciete 'approved' brengt het idee naar PLAN_REVIEWED. - // 'rejected', 'pending' én een weggelaten approval_status betekenen - // allemaal "niet auto-goedgekeurd — mens moet beslissen" en gaan naar - // PLAN_REVIEW_FAILED. Nooit stilzwijgend goedkeuren (de vorige - // `: 'PLAN_REVIEWED'`-default deed dat wel bij pending/undefined). - const nextStatus = - approval_status === 'approved' ? 'PLAN_REVIEWED' : 'PLAN_REVIEW_FAILED' - - // Log summary metrics from review_log - const logSummary = buildReviewLogSummary(review_log) - - const result = await prisma.$transaction([ - prisma.idea.update({ - where: { id: idea_id }, - data: { - plan_review_log: review_log as any, - reviewed_at: new Date(), - status: nextStatus, - }, - select: { id: true, status: true, code: true }, - }), - prisma.ideaLog.create({ - data: { - idea_id, - type: 'PLAN_REVIEW_RESULT', - content: logSummary.summary, - metadata: { - approval_status, - convergence_status: logSummary.convergence_status, - final_score: logSummary.final_score, - rounds_completed: logSummary.rounds_completed, - }, - }, - }), - ]) - - return toolJson({ - ok: true, - idea: result[0], - review_log_summary: logSummary, - }) - }) -} - -export function registerUpdateIdeaPlanReviewedTool(server: McpServer) { - server.registerTool( - 'update_idea_plan_reviewed', - { - title: 'Mark plan as reviewed', - description: - 'Save review-log after a plan review cycle and transition idea.status. ' + - 'Only approval_status="approved" → PLAN_REVIEWED; "rejected", "pending", ' + - 'or an omitted approval_status → PLAN_REVIEW_FAILED (needs manual ' + - 'approval — never silently approved). Forbidden for demo accounts.', - inputSchema, - }, - handleUpdateIdeaPlanReviewed, - ) -} - -function buildReviewLogSummary( - reviewLog: Record, -): { - summary: string - convergence_status: string - final_score: number - rounds_completed: number -} { - const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : [] - const convergence = reviewLog.convergence || {} - const finalScore = - rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0 - - const convergenceStatus = - convergence.stable_at_round !== undefined - ? `stable at round ${convergence.stable_at_round}` - : convergence.final_diff_pct !== undefined - ? `${convergence.final_diff_pct}% diff` - : 'pending' - - const summary = - `Plan reviewed in ${rounds.length} rounds. ` + - `Convergence: ${convergenceStatus}. ` + - `Final score: ${finalScore}/100. ` + - `Status: ${reviewLog.approval?.status || 'pending'}.` - - return { - summary, - convergence_status: convergenceStatus, - final_score: finalScore, - rounds_completed: rounds.length, - } -} diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 9fcd08b..41eb9cd 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -1,12 +1,6 @@ -// update_job_status — agent rapporteert voortgang: running | done | failed | skipped. +// update_job_status — agent rapporteert voortgang: running | done | failed. // Auth: Bearer-token moet matchen claimed_by_token_id van de job. // Triggert automatisch een SSE-event naar de UI via pg_notify. -// -// 'skipped' is de no-op exit voor TASK_IMPLEMENTATION jobs waar verify_task_against_plan -// EMPTY oplevert omdat de wijzigingen al in origin/main staan (parallel werk, eerdere -// PR, race tussen siblings). Geen verify-gate, geen PR, geen cascade. De worker moet -// de bijbehorende task apart op DONE zetten via update_task_status als de inhoudelijke -// vereisten al zijn voldaan. import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -17,35 +11,15 @@ import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { toolJson, toolError, withToolErrors } from '../errors.js' import { removeWorktreeForJob } from '../git/worktree.js' -import { getWorktreeRoot } from '../git/worktree-paths.js' -import { releaseLocksOnTerminal } from '../git/job-locks.js' import { resolveRepoRoot } from './wait-for-job.js' import { pushBranchForJob } from '../git/push.js' -import { createPullRequest, markPullRequestReady } from '../git/pr.js' +import { createPullRequest } from '../git/pr.js' import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js' import { propagateStatusUpwards } from '../lib/tasks-status-update.js' -import { triggerPush } from '../lib/push-trigger.js' -import { transition as prFlowTransition } from '../flow/pr-flow.js' -import { transition as sprintRunTransition } from '../flow/sprint-run.js' -import { executeEffects } from '../flow/effects.js' -import { execFile as execFileCb } from 'node:child_process' -import { promisify } from 'node:util' - -const execGh = promisify(execFileCb) - -async function fetchConflictFiles(prUrl: string): Promise { - try { - const { stdout } = await execGh('gh', ['pr', 'view', prUrl, '--json', 'files']) - const parsed = JSON.parse(stdout) as { files?: Array<{ path: string }> } - return parsed.files?.map((f) => f.path) ?? [] - } catch { - return [] - } -} const inputSchema = z.object({ job_id: z.string().min(1), - status: z.enum(['running', 'done', 'failed', 'skipped']), + status: z.enum(['running', 'done', 'failed']), branch: z.string().min(1).optional(), summary: z.string().max(1_000).optional(), error: z.string().max(2_000).optional(), @@ -54,13 +28,12 @@ const inputSchema = z.object({ output_tokens: z.number().int().nonnegative().optional(), cache_read_tokens: z.number().int().nonnegative().optional(), cache_write_tokens: z.number().int().nonnegative().optional(), - actual_thinking_tokens: z.number().int().nonnegative().optional(), }) export async function cleanupWorktreeForTerminalStatus( productId: string, jobId: string, - status: 'done' | 'failed' | 'skipped', + status: 'done' | 'failed', branch: string | undefined, ): Promise { const repoRoot = await resolveRepoRoot(productId) @@ -71,57 +44,31 @@ export async function cleanupWorktreeForTerminalStatus( return } - // Branch-shared check: bepaal welke siblings dezelfde branch reuse'n. - // - SPRINT pr_strategy → alle TASK_IMPLEMENTATION jobs in dezelfde - // sprint_run delen feat/sprint-. - // - STORY pr_strategy / legacy → alle TASK_IMPLEMENTATION jobs in - // dezelfde story delen feat/story-. - // Bij active siblings: defer cleanup (en in elk geval keepBranch=true) - // zodat de volgende claim de branch kan reuse'n. + // Branch-per-story: only remove the worktree if no sibling job in the same + // story is still active. If siblings are queued/claimed/running they will + // re-use this branch — destroying the worktree now wastes the next claim. const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, - select: { - task: { select: { story_id: true } }, - sprint_run_id: true, - sprint_run: { select: { pr_strategy: true } }, - }, + select: { task: { select: { story_id: true } } }, }) - - let activeSiblings = 0 - let scope = '' - if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { - activeSiblings = await prisma.claudeJob.count({ - where: { - sprint_run_id: job.sprint_run_id, - status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - id: { not: jobId }, - }, - }) - scope = `sprint_run ${job.sprint_run_id}` - } else if (job?.task) { - activeSiblings = await prisma.claudeJob.count({ + if (job?.task) { + const activeSiblings = await prisma.claudeJob.count({ where: { task: { story_id: job.task.story_id }, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, id: { not: jobId }, }, }) - scope = `story ${job.task.story_id}` + if (activeSiblings > 0) { + console.log( + `[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in story ${job.task.story_id}`, + ) + return + } } - if (activeSiblings > 0) { - console.log( - `[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in ${scope}`, - ) - return - } - - // Keep branch when: - // - job is done en agent rapporteerde push (branch !== undefined), of - // - SPRINT pr_strategy job is skipped — andere stories delen branch. - const keepBranch = - (status === 'done' && branch !== undefined) || - (status === 'skipped' && job?.sprint_run?.pr_strategy === 'SPRINT') + // Keep branch when job is done and a branch was reported (agent pushed) + const keepBranch = status === 'done' && branch !== undefined try { await removeWorktreeForJob({ repoRoot, jobId, keepBranch }) } catch (err) { @@ -138,53 +85,26 @@ export type DoneUpdatePlan = { branchOverride: string | undefined errorOverride: string | undefined skipWorktreeCleanup: boolean - headSha: string | undefined } export async function prepareDoneUpdate( jobId: string, branch: string | undefined, ): Promise { - // Resolve branch in deze volgorde: - // 1. Expliciete `branch`-arg van Claude (meestal niet meegegeven). - // 2. ClaudeJob.branch uit de DB — gezet door attachWorktreeToJob met de - // juiste pr_strategy: feat/sprint- voor SPRINT, feat/story- - // voor STORY met sibling-reuse. - // 3. Legacy fallback feat/job-<8> — alleen voor jobs zonder DB-branch - // (zou niet moeten voorkomen na PBI-50). - let resolvedBranch = branch - if (!resolvedBranch) { - const dbJob = await prisma.claudeJob.findUnique({ - where: { id: jobId }, - select: { branch: true }, - }) - resolvedBranch = dbJob?.branch ?? undefined - } - const branchName = resolvedBranch ?? `feat/job-${jobId.slice(-8)}` - - const worktreeDir = getWorktreeRoot() + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees') const worktreePath = path.join(worktreeDir, jobId) + const branchName = branch ?? `feat/job-${jobId.slice(-8)}` const pushResult = await pushBranchForJob({ worktreePath, branchName }) if (pushResult.pushed) { - let headSha: string | undefined - try { - const { execFile } = await import('node:child_process') - const { promisify } = await import('node:util') - const exec = promisify(execFile) - const { stdout } = await exec('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }) - headSha = stdout.trim() - } catch (err) { - console.warn(`[prepareDoneUpdate] failed to resolve HEAD sha for job ${jobId}:`, err) - } return { dbStatus: 'DONE', pushedAt: new Date(), branchOverride: branchName, errorOverride: undefined, skipWorktreeCleanup: false, - headSha, } } @@ -195,7 +115,6 @@ export async function prepareDoneUpdate( branchOverride: undefined, errorOverride: undefined, skipWorktreeCleanup: false, - headSha: undefined, } } @@ -207,7 +126,6 @@ export async function prepareDoneUpdate( branchOverride: undefined, errorOverride: `push failed (${pushResult.reason}): ${snippet}`, skipWorktreeCleanup: true, - headSha: undefined, } } @@ -275,147 +193,20 @@ export function checkVerifyGate( return { allowed: true } } -// PBI-50 F4-T1: aggregate verify-gate voor SPRINT_IMPLEMENTATION DONE. -// Bron: alleen SprintTaskExecution-rows voor deze job. Per row: -// DONE → checkVerifyGate met snapshot-velden (gate per row) -// SKIPPED → alleen toegestaan als verify_required_snapshot === 'ANY' -// FAILED/PENDING/RUNNING → blocker (sprint mag niet DONE met openstaand werk) -// Bij overall pass → { allowed: true }; anders error met opsomming. -export async function checkSprintVerifyGate( - sprintJobId: string, -): Promise<{ allowed: true } | { allowed: false; error: string }> { - const executions = await prisma.sprintTaskExecution.findMany({ - where: { sprint_job_id: sprintJobId }, - orderBy: { order: 'asc' }, - select: { - id: true, - task_id: true, - order: true, - status: true, - verify_result: true, - verify_summary: true, - verify_required_snapshot: true, - verify_only_snapshot: true, - task: { select: { code: true, title: true } }, - }, - }) - if (executions.length === 0) { - return { - allowed: false, - error: - 'Sprint-job heeft geen SprintTaskExecution-rows. ' + - 'Dit duidt op een claim-bug; reclaim de sprint.', - } - } - - const blockers: string[] = [] - for (const exec of executions) { - const taskLabel = `${exec.task.code}: ${exec.task.title}` - if (exec.status === 'PENDING' || exec.status === 'RUNNING') { - blockers.push(`[${exec.status}] ${taskLabel} — onafgemaakt werk`) - continue - } - if (exec.status === 'FAILED') { - blockers.push(`[FAILED] ${taskLabel}`) - continue - } - if (exec.status === 'SKIPPED') { - if (exec.verify_required_snapshot !== 'ANY') { - blockers.push( - `[SKIPPED] ${taskLabel} — alleen toegestaan bij verify_required=ANY`, - ) - } - continue - } - // DONE: per-row gate - const gate = checkVerifyGate( - exec.verify_result, - exec.verify_only_snapshot, - exec.verify_required_snapshot, - exec.verify_summary ?? undefined, - ) - if (!gate.allowed) { - blockers.push(`[DONE-gate] ${taskLabel}: ${gate.error}`) - } - } - - if (blockers.length === 0) return { allowed: true } - return { - allowed: false, - error: - `Sprint kan niet DONE — ${blockers.length} task(s) blokkeren:\n` + - blockers.map((b) => ` - ${b}`).join('\n'), - } -} - -// PBI-50 F4-T2: idempotent SprintRun-finalisering. -// Invariant: alleen aanroepen wanneer alle stories in de sprint status -// DONE/FAILED/CANCELLED hebben. Effect: SprintRun.status → DONE + -// finished_at = NOW(). Idempotent — bij al-DONE: no-op. -export async function finalizeSprintRunOnDone(sprintRunId: string): Promise { - const sprintRun = await prisma.sprintRun.findUnique({ - where: { id: sprintRunId }, - select: { id: true, status: true, sprint_id: true }, - }) - if (!sprintRun) return - if (sprintRun.status === 'DONE') return // idempotent - - // Check alle stories in deze sprint zijn klaar - const openStories = await prisma.story.count({ - where: { - sprint_id: sprintRun.sprint_id, - status: { notIn: ['DONE', 'FAILED'] }, - }, - }) - if (openStories > 0) return // nog werk over — niet finaliseren - - await prisma.sprintRun.update({ - where: { id: sprintRunId }, - data: { status: 'DONE', finished_at: new Date() }, - }) -} - const DB_STATUS_MAP = { running: 'RUNNING', done: 'DONE', failed: 'FAILED', - skipped: 'SKIPPED', } as const export function resolveNextAction( queueCount: number, - status: 'running' | 'done' | 'failed' | 'skipped', + status: 'running' | 'done' | 'failed', ): 'wait_for_job_again' | 'queue_empty' | 'idle' { if (status === 'running') return 'idle' return queueCount > 0 ? 'wait_for_job_again' : 'queue_empty' } -export type JobTimestampUpdate = { - claimed_at?: Date - started_at?: Date - finished_at?: Date -} - -// Bepaalt welke lifecycle-timestamps update_job_status schrijft bij een -// status-overgang. Set-once (backfill alleen als nu null) houdt de invariant -// claimed_at ≤ started_at ≤ finished_at: een job die CLAIMED → done gaat -// zonder `running`-rapport krijgt alsnog een started_at, en claimed_at -// (normaal door wait_for_job bij claim gezet) wordt nooit overschreven. -export function resolveJobTimestamps( - status: 'running' | 'done' | 'failed' | 'skipped', - current: { claimed_at: Date | null; started_at: Date | null }, - now: Date = new Date(), -): JobTimestampUpdate { - const isTerminal = status === 'done' || status === 'failed' || status === 'skipped' - const update: JobTimestampUpdate = {} - if (current.claimed_at == null) update.claimed_at = now - if (current.started_at == null && (status === 'running' || isTerminal)) { - update.started_at = now - } - if (isTerminal) update.finished_at = now - return update -} - export async function maybeCreateAutoPr(opts: { jobId: string productId: string @@ -432,86 +223,29 @@ export async function maybeCreateAutoPr(opts: { }) if (!product?.auto_pr) return null - const job = await prisma.claudeJob.findUnique({ - where: { id: jobId }, - select: { - sprint_run_id: true, - sprint_run: { - select: { id: true, pr_strategy: true, sprint: { select: { sprint_goal: true } } }, - }, - }, - }) - const task = await prisma.task.findUnique({ where: { id: taskId }, select: { title: true, - repo_url: true, story: { select: { id: true, code: true, title: true } }, }, }) if (!task) return null - // Cross-repo sprints: een sprint kan taken hebben die via task.repo_url een - // ander repo targeten. PRs en branches zijn per-repo, dus een sibling-PR mag - // alleen hergebruikt worden als die sibling hetzelfde repo targette. null/leeg - // repo_url = het product-repo; twee taken zitten in dezelfde repo-bucket als - // hun (repo_url ?? null) gelijk is. - const thisRepoKey = task.repo_url ?? null - - // PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun (per repo). - // Mens zet 'm ready-for-review zodra de SprintRun DONE is. - if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { - const sprintSiblings = await prisma.claudeJob.findMany({ - where: { - sprint_run_id: job.sprint_run_id, - pr_url: { not: null }, - id: { not: jobId }, - }, - select: { pr_url: true, task: { select: { repo_url: true } } }, - orderBy: { created_at: 'asc' }, - }) - const sameRepoSibling = sprintSiblings.find( - (s) => (s.task?.repo_url ?? null) === thisRepoKey, - ) - if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url - - // Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge. - const goal = job.sprint_run.sprint.sprint_goal - const sprintTitle = `Sprint: ${goal}`.slice(0, 200) - const body = summary - ? `${summary}\n\n---\n\n*Draft PR voor sprint-run \`${job.sprint_run.id}\`. Wordt ready-for-review zodra alle stories DONE zijn (auto-merge bewust uit voor sprint-mode).*` - : `*Draft PR voor sprint-run \`${job.sprint_run.id}\`. Wordt ready-for-review zodra alle stories DONE zijn (auto-merge bewust uit voor sprint-mode).*` - - const result = await createPullRequest({ - worktreePath, - branchName, - title: sprintTitle, - body, - draft: true, - enableAutoMerge: false, - }) - if ('url' in result) return result.url - console.warn(`[update_job_status] sprint draft-PR skipped for job ${jobId}:`, result.error) - return null - } - - // STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR - // — maar alleen siblings die hetzelfde repo targeten (zie thisRepoKey). - const storySiblings = await prisma.claudeJob.findMany({ + // Branch-per-story: if a sibling job in the same story already opened a PR, + // reuse its URL. This avoids one PR per sub-task. + const sibling = await prisma.claudeJob.findFirst({ where: { task: { story_id: task.story.id }, pr_url: { not: null }, id: { not: jobId }, }, - select: { pr_url: true, task: { select: { repo_url: true } } }, + select: { pr_url: true }, orderBy: { created_at: 'asc' }, }) - const sameRepoStorySibling = storySiblings.find( - (s) => (s.task?.repo_url ?? null) === thisRepoKey, - ) - if (sameRepoStorySibling?.pr_url) return sameRepoStorySibling.pr_url + if (sibling?.pr_url) return sibling.pr_url + // First DONE-task in the story → create a story-scoped PR const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title const body = summary ? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent (first task in story; PR-body will accumulate as sibling tasks complete).*` @@ -524,68 +258,6 @@ export async function maybeCreateAutoPr(opts: { return null } -// PBI-50 F4-T2: SPRINT_BATCH PR-flow. Eén draft-PR voor de hele sprint, -// title = sprint.sprint_goal. Mens reviewt + mergt zelf — geen auto-merge. -// Lijkt op de SPRINT-mode van maybeCreateAutoPr maar zonder task-context. -export async function maybeCreateSprintBatchPr(opts: { - jobId: string - productId: string - worktreePath: string - branchName: string - summary: string | undefined -}): Promise { - const { jobId, productId, worktreePath, branchName, summary } = opts - - const product = await prisma.product.findUnique({ - where: { id: productId }, - select: { auto_pr: true }, - }) - if (!product?.auto_pr) return null - - const job = await prisma.claudeJob.findUnique({ - where: { id: jobId }, - select: { - sprint_run_id: true, - sprint_run: { - select: { id: true, sprint: { select: { sprint_goal: true } } }, - }, - }, - }) - if (!job?.sprint_run) return null - - // Resume-pad: oude SprintRun heeft mogelijk al een PR via vorige run-job. - // Lookup via SprintRunChain (previous_run_id) of via sibling-SPRINT-job. - const previousRun = await prisma.sprintRun.findUnique({ - where: { id: job.sprint_run.id }, - select: { previous_run_id: true }, - }) - if (previousRun?.previous_run_id) { - const prevPr = await prisma.claudeJob.findFirst({ - where: { sprint_run_id: previousRun.previous_run_id, pr_url: { not: null } }, - select: { pr_url: true }, - }) - if (prevPr?.pr_url) return prevPr.pr_url - } - - const goal = job.sprint_run.sprint.sprint_goal - const sprintTitle = `Sprint: ${goal}`.slice(0, 200) - const body = summary - ? `${summary}\n\n---\n\n*Draft PR voor sprint-batch \`${job.sprint_run.id}\` (single-session). Wordt ready-for-review zodra alle tasks DONE zijn.*` - : `*Draft PR voor sprint-batch \`${job.sprint_run.id}\` (single-session). Wordt ready-for-review zodra alle tasks DONE zijn.*` - - const result = await createPullRequest({ - worktreePath, - branchName, - title: sprintTitle, - body, - draft: true, - enableAutoMerge: false, - }) - if ('url' in result) return result.url - console.warn(`[update_job_status] sprint-batch draft-PR skipped for job ${jobId}:`, result.error) - return null -} - export function registerUpdateJobStatusTool(server: McpServer) { server.registerTool( 'update_job_status', @@ -593,20 +265,13 @@ export function registerUpdateJobStatusTool(server: McpServer) { title: 'Update job status', description: 'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' + - 'running (start), done (finished), failed (error), skipped (no-op exit). ' + + 'running (start), done (finished), failed (error). ' + 'The Bearer token must match the token that claimed the job. ' + - 'Stamps started_at on running and finished_at on done/failed/skipped, and backfills ' + - 'claimed_at/started_at when missing so claimed_at ≤ started_at ≤ finished_at always holds. ' + 'Before marking done: call verify_task_against_plan first — done is rejected when ' + 'verify_result is null, EMPTY (unless task.verify_only is true), or when the verify level ' + 'doesn’t meet task.verify_required: ALIGNED-only is strict; ALIGNED_OR_PARTIAL accepts ' + 'PARTIAL/DIVERGENT but requires a non-empty summary (≥20 chars) explaining the drift; ANY ' + 'accepts everything. ' + - "Use 'skipped' for TASK_IMPLEMENTATION when verify_task_against_plan returns EMPTY because " + - 'the requested changes are already present in origin/main (parallel work, earlier PR, race ' + - "between siblings). 'skipped' requires a non-empty error (≥10 chars) describing the reason " + - "(e.g. 'no_op_changes_already_in_main') and skips the verify-gate, auto-PR and PBI fail-cascade. " + - 'Mark the underlying task DONE separately via update_task_status if its requirements are met. ' + 'Automatically emits an SSE event so the Scrum4Me UI updates in real time. ' + 'Optionally accepts token-usage fields (model_id + input/output/cache_read/cache_write tokens) ' + 'for cost tracking — typically populated by a PostToolUse hook from the local Claude Code transcript, ' + @@ -625,7 +290,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { output_tokens, cache_read_tokens, cache_write_tokens, - actual_thinking_tokens, }) => withToolErrors(async () => { const auth = await requireWriteAccess() @@ -636,14 +300,11 @@ export function registerUpdateJobStatusTool(server: McpServer) { select: { id: true, status: true, - claimed_at: true, - started_at: true, claimed_by_token_id: true, user_id: true, product_id: true, task_id: true, idea_id: true, - sprint_run_id: true, kind: true, verify_result: true, task: { select: { verify_only: true, verify_required: true } }, @@ -667,30 +328,12 @@ export function registerUpdateJobStatusTool(server: McpServer) { return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`) } - // 'skipped' = no-op exit. Only valid for TASK_IMPLEMENTATION (verify=EMPTY - // patroon) en vereist een non-empty error met ≥10 chars uitleg, zoals - // 'no_op_changes_already_in_main'. Geen verify-gate, geen PR, geen - // PBI fail-cascade, geen propagation naar task/story/PBI. - if (status === 'skipped') { - if (job.kind !== 'TASK_IMPLEMENTATION') { - return toolError( - `'skipped' is alleen toegestaan voor TASK_IMPLEMENTATION (kind=${job.kind})`, - ) - } - if (!error || error.trim().length < 10) { - return toolError( - "'skipped' vereist non-empty error met reden (≥10 chars), bv. 'no_op_changes_already_in_main'", - ) - } - } - // For DONE: push first, adjust DB status based on result let actualStatus = status let pushedAt: Date | undefined let branchToWrite = branch let errorToWrite = error let skipWorktreeCleanup = false - let headShaToWrite: string | undefined if (status === 'done') { // M12: idea-jobs hebben geen task/plan_snapshot/branch — skip de @@ -701,19 +344,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { actualStatus = 'done' // pushedAt blijft undefined, branch/error overrides ook skipWorktreeCleanup = true - } else if (job.kind === 'SPRINT_IMPLEMENTATION') { - // PBI-50 F4-T2: aggregate verify-gate via SprintTaskExecution-rows. - // Geen single-task verify_result op de SPRINT-job zelf. - const gate = await checkSprintVerifyGate(job_id) - if (!gate.allowed) return toolError(gate.error) - - const plan = await prepareDoneUpdate(job_id, branch) - actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed' - pushedAt = plan.pushedAt - if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride - if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride - skipWorktreeCleanup = plan.skipWorktreeCleanup - headShaToWrite = plan.headSha } else { const gate = checkVerifyGate( job.verify_result ?? null, @@ -729,13 +359,11 @@ export function registerUpdateJobStatusTool(server: McpServer) { if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride skipWorktreeCleanup = plan.skipWorktreeCleanup - headShaToWrite = plan.headSha } } // Auto-PR: best-effort, only when push actually happened. // M12: idee-jobs hebben geen task_id en geen branch — skip auto-PR. - // PBI-50: SPRINT_IMPLEMENTATION krijgt een eigen PR-flow (sprint-goal als title). let prUrl: string | null = null if ( actualStatus === 'done' && @@ -744,7 +372,9 @@ export function registerUpdateJobStatusTool(server: McpServer) { job.kind === 'TASK_IMPLEMENTATION' && job.task_id ) { - const worktreeDir = getWorktreeRoot() + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') prUrl = await maybeCreateAutoPr({ jobId: job_id, productId: job.product_id, @@ -756,23 +386,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err) return null }) - } else if ( - actualStatus === 'done' && - pushedAt && - branchToWrite && - job.kind === 'SPRINT_IMPLEMENTATION' - ) { - const worktreeDir = getWorktreeRoot() - prUrl = await maybeCreateSprintBatchPr({ - jobId: job_id, - productId: job.product_id, - worktreePath: path.join(worktreeDir, job_id), - branchName: branchToWrite, - summary, - }).catch((err) => { - console.warn(`[update_job_status] sprint-batch PR error for job ${job_id}:`, err) - return null - }) } const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP] @@ -781,23 +394,18 @@ export function registerUpdateJobStatusTool(server: McpServer) { where: { id: job_id }, data: { status: dbStatus, - ...resolveJobTimestamps( - actualStatus, - { claimed_at: job.claimed_at, started_at: job.started_at }, - now, - ), + ...(actualStatus === 'running' ? { started_at: now } : {}), + ...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}), ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}), ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}), ...(errorToWrite !== undefined ? { error: errorToWrite } : {}), ...(prUrl !== null ? { pr_url: prUrl } : {}), - ...(headShaToWrite !== undefined ? { head_sha: headShaToWrite } : {}), ...(model_id !== undefined ? { model_id } : {}), ...(input_tokens !== undefined ? { input_tokens } : {}), ...(output_tokens !== undefined ? { output_tokens } : {}), ...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}), ...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}), - ...(actual_thinking_tokens !== undefined ? { actual_thinking_tokens } : {}), }, select: { id: true, @@ -810,7 +418,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { error: true, started_at: true, finished_at: true, - head_sha: true, }, }) @@ -818,20 +425,13 @@ export function registerUpdateJobStatusTool(server: McpServer) { // bij elke task-statusovergang (DONE of FAILED). De helper handelt ook // sibling-cancel binnen dezelfde SprintRun af bij FAILED. // Idea-jobs hebben geen task_id en worden hier overgeslagen. - let sprintRunBecameDone = false - let storyBecameDone = false if ( (actualStatus === 'done' || actualStatus === 'failed') && job.kind === 'TASK_IMPLEMENTATION' && job.task_id ) { try { - const propagation = await propagateStatusUpwards( - job.task_id, - actualStatus === 'done' ? 'DONE' : 'FAILED', - ) - sprintRunBecameDone = actualStatus === 'done' && propagation.sprintRunChanged - storyBecameDone = actualStatus === 'done' && propagation.storyChanged + await propagateStatusUpwards(job.task_id, actualStatus === 'done' ? 'DONE' : 'FAILED') } catch (err) { console.warn( `[update_job_status] propagateStatusUpwards error for task ${job.task_id}:`, @@ -840,113 +440,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { } } - // PBI-47 (P0): STORY-mode auto-merge timing fix. - // Only enable auto-merge when this DONE was the *last* task of a STORY - // (story.status flipped to DONE) and pr_strategy === STORY. The - // pr-flow transition emits ENABLE_AUTO_MERGE with the head_sha guard. - if ( - storyBecameDone && - updated.pr_url && - headShaToWrite && - job.kind === 'TASK_IMPLEMENTATION' - ) { - const storyCtx = await prisma.claudeJob.findUnique({ - where: { id: job_id }, - select: { - task: { select: { story: { select: { status: true } } } }, - sprint_run: { select: { pr_strategy: true } }, - }, - }) - if ( - storyCtx?.sprint_run?.pr_strategy === 'STORY' - && storyCtx.task?.story.status === 'DONE' - ) { - const result = prFlowTransition( - { kind: 'pr_opened', strategy: 'STORY', prUrl: updated.pr_url }, - { - type: 'STORY_COMPLETED', - storyId: '', - headSha: headShaToWrite, - }, - ) - const outcomes = await executeEffects(result.effects) - // PBI-47 (C2): route MERGE_CONFLICT to sprint-run flow → PAUSED. - // Other reasons (CHECKS_FAILED, GH_AUTH_ERROR, AUTO_MERGE_NOT_ALLOWED, UNKNOWN) - // remain warnings; CHECKS_FAILED is already covered by the task-FAIL cascade. - for (const o of outcomes) { - if (o.effect === 'ENABLE_AUTO_MERGE' && !o.ok) { - console.warn( - `[update_job_status] auto-merge fail for ${updated.pr_url}: ${o.reason} ${o.stderr.slice(0, 200)}`, - ) - if (o.reason === 'MERGE_CONFLICT') { - const sprintRunId = await prisma.claudeJob - .findUnique({ - where: { id: job_id }, - select: { sprint_run_id: true }, - }) - .then((j) => j?.sprint_run_id) - if (sprintRunId) { - const conflictFiles = await fetchConflictFiles(updated.pr_url) - const conflictResult = sprintRunTransition( - { kind: 'running', sprintRunId }, - { - type: 'MERGE_CONFLICT', - prUrl: updated.pr_url, - prHeadSha: headShaToWrite ?? '', - conflictFiles, - resumeInstructions: - 'Resolve the conflict on this branch, push, then resume the sprint via the UI.', - }, - ) - await executeEffects(conflictResult.effects) - } - } - } - } - } - } - - // SPRINT-mode: bij sprint-DONE de draft-PR ready-for-review zetten. - // Mens reviewt + mergt zelf — geen auto-merge in deze modus. - // PBI-49 P2: gebruik niet alleen updated.pr_url — als de laatste task - // verify-only is of geen wijzigingen pusht, krijgt die geen pr_url. - // Zoek de eerst aangemaakte PR op binnen de SprintRun als fallback. - if (sprintRunBecameDone) { - const ctx = await prisma.claudeJob - .findUnique({ - where: { id: job_id }, - select: { - sprint_run_id: true, - sprint_run: { select: { pr_strategy: true, status: true } }, - }, - }) - if ( - ctx?.sprint_run?.pr_strategy === 'SPRINT' - && ctx.sprint_run.status === 'DONE' - && ctx.sprint_run_id - ) { - const sprintPrUrl = updated.pr_url - ?? (await prisma.claudeJob.findFirst({ - where: { sprint_run_id: ctx.sprint_run_id, pr_url: { not: null } }, - orderBy: { created_at: 'asc' }, - select: { pr_url: true }, - }))?.pr_url - ?? null - if (sprintPrUrl) { - try { - const ready = await markPullRequestReady({ prUrl: sprintPrUrl }) - if ('error' in ready) { - console.warn( - `[update_job_status] markPullRequestReady failed for ${sprintPrUrl}: ${ready.error}`, - ) - } - } catch (err) { - console.warn(`[update_job_status] markPullRequestReady error:`, err) - } - } - } - } - // M12: bij failed voor IDEA_*-jobs: zet idea.status op // GRILL_FAILED / PLAN_FAILED + log JOB_EVENT. Bij done laten we de // idea-status met rust — die wordt door update_idea_*_md gezet. @@ -979,45 +472,32 @@ export function registerUpdateJobStatusTool(server: McpServer) { try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) await pg.connect() - const notifyPayload: Record = { - type: 'claude_job_status', - job_id: updated.id, - user_id: job.user_id, - product_id: job.product_id, - status: actualStatus, - branch: updated.branch ?? undefined, - pushed_at: updated.pushed_at?.toISOString() ?? undefined, - pr_url: updated.pr_url ?? undefined, - verify_result: updated.verify_result?.toLowerCase() ?? undefined, - summary: updated.summary ?? undefined, - error: updated.error ?? undefined, - } - if (job.task_id) notifyPayload.task_id = job.task_id - if (job.idea_id) { - notifyPayload.idea_id = job.idea_id - notifyPayload.kind = job.kind - } - await pg.query(`SELECT pg_notify('scrum4me_changes', $1)`, [JSON.stringify(notifyPayload)]) + await pg.query( + `SELECT pg_notify('scrum4me_changes', $1)`, + [ + JSON.stringify({ + type: 'claude_job_status', + job_id: updated.id, + task_id: job.task_id, + user_id: job.user_id, + product_id: job.product_id, + status: actualStatus, + branch: updated.branch ?? undefined, + pushed_at: updated.pushed_at?.toISOString() ?? undefined, + pr_url: updated.pr_url ?? undefined, + verify_result: updated.verify_result?.toLowerCase() ?? undefined, + summary: updated.summary ?? undefined, + error: updated.error ?? undefined, + }), + ], + ) await pg.end() } catch { // non-fatal — status is already persisted } - if (actualStatus === 'failed' || actualStatus === 'done') { - const isFailed = actualStatus === 'failed' - void triggerPush(job.user_id, { - title: isFailed ? 'Job gefaald' : 'Job klaar', - body: (updated.summary ?? updated.error ?? `Job ${updated.id}`).slice(0, 120), - url: updated.pr_url ?? '/dashboard', - tag: `job-${updated.id}`, - }) - } - // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) - if ( - (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') && - !skipWorktreeCleanup - ) { + if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) { await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite) } @@ -1025,94 +505,10 @@ export function registerUpdateJobStatusTool(server: McpServer) { // cancel all queued/claimed/running siblings under the same PBI and // undo any pushed commits (close open PRs / open revert-PRs for // already-merged ones). Idempotent + non-blocking — never throws. - // PBI-50: SPRINT_IMPLEMENTATION SKIPS this — cascade naar tasks/stories/ - // PBIs is al gebeurd via per-task update_task_status('failed')-calls - // van de worker. Sprint-job heeft geen task_id; cancelPbi-flow past niet. if (actualStatus === 'failed' && job.kind === 'TASK_IMPLEMENTATION' && job.task_id) { await cancelPbiOnFailure(job_id) } - // PBI-50 F4-T2: SPRINT_IMPLEMENTATION DONE → finalize SprintRun. - if ( - actualStatus === 'done' && - job.kind === 'SPRINT_IMPLEMENTATION' && - job.sprint_run_id - ) { - try { - await finalizeSprintRunOnDone(job.sprint_run_id) - // Mark draft-PR ready-for-review als de SprintRun nu DONE is - const finalRun = await prisma.sprintRun.findUnique({ - where: { id: job.sprint_run_id }, - select: { status: true }, - }) - if (finalRun?.status === 'DONE' && updated.pr_url) { - try { - const ready = await markPullRequestReady({ prUrl: updated.pr_url }) - if ('error' in ready) { - console.warn( - `[update_job_status] sprint-batch markPullRequestReady failed for ${updated.pr_url}: ${ready.error}`, - ) - } - } catch (err) { - console.warn(`[update_job_status] sprint-batch markPullRequestReady error:`, err) - } - } - } catch (err) { - console.warn(`[update_job_status] finalizeSprintRunOnDone error:`, err) - } - } - - // PBI-50 F4-T3: SPRINT_IMPLEMENTATION FAILED → - // - Detect QUOTA_PAUSE: error-prefix → PAUSED met pause_context. - // - Anders: vul SprintRun.failure_reason + failed_task_id (uit error). - if (actualStatus === 'failed' && job.kind === 'SPRINT_IMPLEMENTATION' && job.sprint_run_id) { - const isQuotaPause = (errorToWrite ?? '').startsWith('QUOTA_PAUSE:') - if (isQuotaPause) { - // Vind laatst-DONE execution voor pause-context - const lastDone = await prisma.sprintTaskExecution.findFirst({ - where: { sprint_job_id: job_id, status: 'DONE' }, - orderBy: { order: 'desc' }, - select: { id: true, order: true, task_id: true }, - }) - await prisma.sprintRun.update({ - where: { id: job.sprint_run_id }, - data: { - status: 'PAUSED', - pause_context: { - pause_reason: 'QUOTA_DEPLETED', - paused_at: new Date().toISOString(), - resume_instructions: - 'Wacht tot quota is gereset, dan resume de SprintRun via de UI. Een nieuwe SprintRun wordt gemaakt met previous_run_id en branch hergebruik.', - last_completed_execution_id: lastDone?.id ?? null, - last_completed_order: lastDone?.order ?? null, - last_completed_task_id: lastDone?.task_id ?? null, - pr_url: updated.pr_url ?? null, - pr_head_sha: updated.head_sha ?? null, - conflict_files: [], - claude_question_id: '', - } as any, - }, - }) - } else { - const failedTaskId = (errorToWrite ?? '').match(/task[:\s]+([a-z0-9]+)/i)?.[1] ?? null - await prisma.sprintRun.update({ - where: { id: job.sprint_run_id }, - data: { - status: 'FAILED', - failure_reason: errorToWrite?.slice(0, 500) ?? null, - failed_task_id: failedTaskId, - finished_at: new Date(), - }, - }) - } - } - - // PBI-9: release product-worktree locks on terminal transitions. - // No-op for jobs without registered locks (i.e. TASK_IMPLEMENTATION). - if (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') { - await releaseLocksOnTerminal(job_id) - } - const queueCount = await prisma.claudeJob.count({ where: { user_id: userId, status: 'QUEUED' }, }) diff --git a/src/tools/update-sprint.ts b/src/tools/update-sprint.ts deleted file mode 100644 index 04800e3..0000000 --- a/src/tools/update-sprint.ts +++ /dev/null @@ -1,102 +0,0 @@ -// MCP tool: update een Sprint. -// -// Generieke update — wijzigt elke combinatie van status, sprint_goal, -// start_date en end_date. Géén state-machine validatie (zie -// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad -// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date -// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt -// daarnaast `completed_at` op now() gezet (parity met -// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via -// task-status-cascade; zo houden reporting en UI één bron van waarheid voor -// completion-tijd). - -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SprintStatus } from '@prisma/client' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { userCanAccessProduct } from '../access.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -const TERMINAL_STATUSES = new Set(['CLOSED', 'FAILED', 'ARCHIVED']) - -export const inputSchema = z.object({ - sprint_id: z.string().min(1), - status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(), - sprint_goal: z.string().min(1).max(500).optional(), - end_date: z.string().date().optional(), - start_date: z.string().date().optional(), -}) - -export async function handleUpdateSprint( - { sprint_id, status, sprint_goal, end_date, start_date }: z.infer, -) { - return withToolErrors(async () => { - if ( - status === undefined && - sprint_goal === undefined && - end_date === undefined && - start_date === undefined - ) { - return toolError('Minstens één veld vereist om te wijzigen') - } - - const auth = await requireWriteAccess() - - const sprint = await prisma.sprint.findUnique({ - where: { id: sprint_id }, - select: { id: true, product_id: true }, - }) - if (!sprint) { - return toolError(`Sprint ${sprint_id} not found`) - } - if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) { - return toolError(`Sprint ${sprint_id} not accessible`) - } - - const data: { - status?: SprintStatus - sprint_goal?: string - start_date?: Date - end_date?: Date - completed_at?: Date - } = {} - if (status !== undefined) data.status = status - if (sprint_goal !== undefined) data.sprint_goal = sprint_goal - if (start_date !== undefined) data.start_date = new Date(start_date) - if (end_date !== undefined) { - data.end_date = new Date(end_date) - } else if (status !== undefined && TERMINAL_STATUSES.has(status)) { - data.end_date = new Date() - } - if (status === 'CLOSED') data.completed_at = new Date() - - const updated = await prisma.sprint.update({ - where: { id: sprint_id }, - data, - select: { - id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - completed_at: true, - }, - }) - return toolJson(updated) - }) -} - -export function registerUpdateSprintTool(server: McpServer) { - server.registerTool( - 'update_sprint', - { - title: 'Update Sprint', - description: - 'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.', - inputSchema, - }, - handleUpdateSprint, - ) -} diff --git a/src/tools/update-task-execution.ts b/src/tools/update-task-execution.ts deleted file mode 100644 index 8b3213a..0000000 --- a/src/tools/update-task-execution.ts +++ /dev/null @@ -1,110 +0,0 @@ -// PBI-50 F3-T2: update_task_execution -// -// SPRINT_IMPLEMENTATION-flow lifecycle-tool. Worker roept dit aan voor elke -// task in de batch om de SprintTaskExecution-row te muteren: -// PENDING → RUNNING → DONE/FAILED/SKIPPED -// Idempotent: dezelfde call kan veilig herhaald worden. - -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -const inputSchema = z.object({ - execution_id: z.string().min(1), - status: z.enum(['PENDING', 'RUNNING', 'DONE', 'FAILED', 'SKIPPED']), - base_sha: z.string().optional(), - head_sha: z.string().optional(), - skip_reason: z.string().max(2000).optional(), -}) - -export function registerUpdateTaskExecutionTool(server: McpServer) { - server.registerTool( - 'update_task_execution', - { - title: 'Update SprintTaskExecution status', - description: - 'Mutate a SprintTaskExecution row in a SPRINT_IMPLEMENTATION batch. ' + - 'Status: PENDING|RUNNING|DONE|FAILED|SKIPPED. Worker calls this for each ' + - 'task transition. Token must own the parent SPRINT_IMPLEMENTATION ClaudeJob. ' + - 'Idempotent — safe to retry. Schrijft started_at (RUNNING) en finished_at ' + - '(DONE/FAILED/SKIPPED). Forbidden for demo accounts.', - inputSchema, - }, - async ({ execution_id, status, base_sha, head_sha, skip_reason }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - - const execution = await prisma.sprintTaskExecution.findUnique({ - where: { id: execution_id }, - select: { - id: true, - sprint_job_id: true, - sprint_job: { - select: { claimed_by_token_id: true, status: true, kind: true }, - }, - }, - }) - if (!execution) { - return toolError(`SprintTaskExecution ${execution_id} not found`) - } - if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') { - return toolError( - `Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`, - ) - } - if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) { - return toolError( - `Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`, - ) - } - if ( - execution.sprint_job.status !== 'CLAIMED' && - execution.sprint_job.status !== 'RUNNING' - ) { - return toolError( - `Sprint job is in terminal state ${execution.sprint_job.status}`, - ) - } - - const now = new Date() - const updated = await prisma.sprintTaskExecution.update({ - where: { id: execution_id }, - data: { - status, - ...(base_sha !== undefined ? { base_sha } : {}), - ...(head_sha !== undefined ? { head_sha } : {}), - ...(skip_reason !== undefined ? { skip_reason } : {}), - ...(status === 'RUNNING' ? { started_at: now } : {}), - ...(status === 'DONE' || status === 'FAILED' || status === 'SKIPPED' - ? { finished_at: now } - : {}), - }, - select: { - id: true, - status: true, - base_sha: true, - head_sha: true, - verify_result: true, - verify_summary: true, - skip_reason: true, - started_at: true, - finished_at: true, - }, - }) - - return toolJson({ - execution_id: updated.id, - status: updated.status, - base_sha: updated.base_sha, - head_sha: updated.head_sha, - verify_result: updated.verify_result, - verify_summary: updated.verify_summary, - skip_reason: updated.skip_reason, - started_at: updated.started_at?.toISOString() ?? null, - finished_at: updated.finished_at?.toISOString() ?? null, - }) - }), - ) -} diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index 8ac8463..d3756ce 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessTask } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' @@ -10,10 +9,6 @@ import { updateTaskStatusWithStoryPromotion } from '../lib/tasks-status-update.j const inputSchema = z.object({ task_id: z.string().min(1), status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]), - // PBI-50: optionele sprint_run_id voor SPRINT_IMPLEMENTATION-flow. - // Wanneer aanwezig: server valideert dat task in deze sprint zit, run - // actief is, en de huidige token een ClaudeJob in deze run heeft geclaimt. - sprint_run_id: z.string().min(1).optional(), }) export function registerUpdateTaskStatusTool(server: McpServer) { @@ -22,14 +17,11 @@ export function registerUpdateTaskStatusTool(server: McpServer) { { title: 'Update task status', description: - 'Set the status of a task. Allowed values: todo, in_progress, review, done, failed. ' + - 'Optional sprint_run_id binds the update to a SPRINT_IMPLEMENTATION run for ' + - 'cascade-propagation; the server validates that the task belongs to the sprint ' + - 'and that the calling token has claimed a job in that run. ' + + 'Set the status of a task. Allowed values: todo, in_progress, review, done. ' + 'Forbidden for demo accounts.', inputSchema, }, - async ({ task_id, status, sprint_run_id }) => + async ({ task_id, status }) => withToolErrors(async () => { const auth = await requireWriteAccess() const dbStatus = taskStatusFromApi(status) @@ -39,74 +31,15 @@ export function registerUpdateTaskStatusTool(server: McpServer) { if (!(await userCanAccessTask(task_id, auth.userId))) { return toolError(`Task ${task_id} not found or not accessible`) } - - // PBI-50: validate explicit sprint_run_id binding. - if (sprint_run_id) { - const sprintRun = await prisma.sprintRun.findUnique({ - where: { id: sprint_run_id }, - select: { id: true, status: true, sprint_id: true }, - }) - if (!sprintRun) { - return toolError(`SprintRun ${sprint_run_id} not found`) - } - if ( - sprintRun.status !== 'QUEUED' && - sprintRun.status !== 'RUNNING' && - sprintRun.status !== 'PAUSED' - ) { - return toolError( - `SprintRun ${sprint_run_id} is in terminal state ${sprintRun.status}; cannot update task status against it`, - ) - } - - // Task moet in deze sprint zitten - const task = await prisma.task.findUnique({ - where: { id: task_id }, - select: { story: { select: { sprint_id: true } } }, - }) - if (!task || task.story.sprint_id !== sprintRun.sprint_id) { - return toolError( - `Task ${task_id} is not in sprint ${sprintRun.sprint_id} (sprint_run ${sprint_run_id})`, - ) - } - - // Token-coupling: huidige token moet een actieve ClaudeJob in deze - // SprintRun hebben geclaimt (typisch de SPRINT_IMPLEMENTATION-job). - const tokenJob = await prisma.claudeJob.findFirst({ - where: { - sprint_run_id, - claimed_by_token_id: auth.tokenId, - status: { in: ['CLAIMED', 'RUNNING'] }, - }, - select: { id: true }, - }) - if (!tokenJob) { - return toolError( - `Forbidden: current token has no active claim in sprint_run ${sprint_run_id}`, - ) - } - } - - const { task, storyStatusChange, sprintRunChanged } = - await updateTaskStatusWithStoryPromotion(task_id, dbStatus, undefined, sprint_run_id) - - // Voor SPRINT-flow: stuur expliciete sprint_run_status mee zodat - // worker zijn loop kan breken bij FAILED/PAUSED zonder extra query. - let sprintRunStatusChange: string | null = null - if (sprintRunChanged && sprint_run_id) { - const updated = await prisma.sprintRun.findUnique({ - where: { id: sprint_run_id }, - select: { status: true }, - }) - sprintRunStatusChange = updated?.status ?? null - } - + const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion( + task_id, + dbStatus, + ) return toolJson({ id: task.id, status: taskStatusToApi(task.status), implementation_plan: task.implementation_plan, story_status_change: storyStatusChange, - sprint_run_status_change: sprintRunStatusChange, }) }), ) diff --git a/src/tools/verify-sprint-task.ts b/src/tools/verify-sprint-task.ts deleted file mode 100644 index fbd62d2..0000000 --- a/src/tools/verify-sprint-task.ts +++ /dev/null @@ -1,151 +0,0 @@ -// PBI-50 F3-T1: verify_sprint_task -// -// Execution-aware verify-tool voor SPRINT_IMPLEMENTATION-flow. -// Verschilt van verify_task_against_plan in: -// - input via execution_id (niet task_id) -// - base_sha komt uit SprintTaskExecution.base_sha; voor task[1..N] zonder -// base_sha vult de tool dynamisch met head_sha van vorige DONE-execution -// - plan_snapshot komt uit execution.plan_snapshot (frozen op claim-tijd) -// - resultaat opgeslagen op execution-row, niet op ClaudeJob.verify_result -// - response geeft allowed_for_done direct mee - -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' -import { classifyDiffAgainstPlan } from '../verify/classify.js' -import { checkVerifyGate } from './update-job-status.js' - -const exec = promisify(execFile) - -const inputSchema = z.object({ - execution_id: z.string().min(1), - worktree_path: z.string().min(1), - summary: z.string().max(2000).optional(), -}) - -export function registerVerifySprintTaskTool(server: McpServer) { - server.registerTool( - 'verify_sprint_task', - { - title: 'Verify SprintTaskExecution against frozen plan', - description: - 'Run `git diff ...HEAD` in the worktree and classify against the ' + - 'frozen plan_snapshot of this SprintTaskExecution. Returns ALIGNED|PARTIAL|EMPTY|' + - 'DIVERGENT plus reasoning + allowed_for_done (computed via the standard verify-gate ' + - 'with the execution\'s frozen verify_required/verify_only). ' + - 'For task[1..N] zonder base_sha vult de tool die in op basis van de head_sha van de ' + - 'vorige DONE-execution. Optional summary is opgeslagen voor PARTIAL/DIVERGENT-rationale ' + - 'en gebruikt door de gate. ' + - 'Call this BEFORE update_task_execution(DONE) for each task in the sprint batch. ' + - 'Forbidden for demo accounts.', - inputSchema, - annotations: { readOnlyHint: false }, - }, - async ({ execution_id, worktree_path, summary }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - - const execution = await prisma.sprintTaskExecution.findUnique({ - where: { id: execution_id }, - select: { - id: true, - sprint_job_id: true, - order: true, - base_sha: true, - plan_snapshot: true, - verify_required_snapshot: true, - verify_only_snapshot: true, - sprint_job: { - select: { claimed_by_token_id: true, status: true, kind: true }, - }, - }, - }) - if (!execution) { - return toolError(`SprintTaskExecution ${execution_id} not found`) - } - if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') { - return toolError( - `Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`, - ) - } - if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) { - return toolError( - `Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`, - ) - } - - // Resolve base_sha. Voor task[0] is dit gevuld bij claim. Voor - // task[1..N] wordt dit dynamisch gevuld op basis van de vorige - // DONE-execution's head_sha. Persist na fill zodat herhaalde calls - // dezelfde base gebruiken. - let baseSha = execution.base_sha - if (!baseSha) { - const previousDone = await prisma.sprintTaskExecution.findFirst({ - where: { - sprint_job_id: execution.sprint_job_id, - order: { lt: execution.order }, - status: 'DONE', - head_sha: { not: null }, - }, - orderBy: { order: 'desc' }, - select: { head_sha: true }, - }) - if (!previousDone?.head_sha) { - return toolError( - `MISSING_BASE_SHA: execution ${execution_id} has no base_sha and no previous DONE-execution with head_sha. Did you skip update_task_execution(DONE) on a prior task?`, - ) - } - baseSha = previousDone.head_sha - await prisma.sprintTaskExecution.update({ - where: { id: execution_id }, - data: { base_sha: baseSha }, - }) - } - - let diff: string - try { - const { stdout } = await exec('git', ['diff', `${baseSha}...HEAD`], { - cwd: worktree_path, - }) - diff = stdout - } catch (err) { - return toolError( - `git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`, - ) - } - - const { result, reasoning } = classifyDiffAgainstPlan({ - diff, - plan: execution.plan_snapshot, - }) - - await prisma.sprintTaskExecution.update({ - where: { id: execution_id }, - data: { - verify_result: result, - ...(summary !== undefined ? { verify_summary: summary } : {}), - }, - }) - - const gate = checkVerifyGate( - result, - execution.verify_only_snapshot, - execution.verify_required_snapshot, - summary, - ) - - return toolJson({ - execution_id: execution.id, - result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent', - reasoning, - base_sha: baseSha, - allowed_for_done: gate.allowed, - reason: gate.allowed ? null : gate.error, - }) - }), - ) -} diff --git a/src/tools/verify-task-against-plan.ts b/src/tools/verify-task-against-plan.ts index e6d03b5..40986ad 100644 --- a/src/tools/verify-task-against-plan.ts +++ b/src/tools/verify-task-against-plan.ts @@ -15,15 +15,8 @@ const inputSchema = z.object({ worktree_path: z.string().min(1), }) -export async function getDiffInWorktree( - worktreePath: string, - baseSha?: string, -): Promise { - // PBI-47 (P0): when base_sha is provided, diff against the per-job base - // captured at claim-time so verify only sees the current task's changes. - // Falls back to origin/main only for legacy callers without base_sha. - const range = baseSha ? `${baseSha}...HEAD` : 'origin/main...HEAD' - const { stdout } = await exec('git', ['diff', range], { cwd: worktreePath }) +export async function getDiffInWorktree(worktreePath: string): Promise { + const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath }) return stdout } @@ -65,7 +58,7 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) { where: { status: { in: ['CLAIMED', 'RUNNING'] } }, orderBy: { created_at: 'desc' }, take: 1, - select: { id: true, plan_snapshot: true, base_sha: true }, + select: { id: true, plan_snapshot: true }, }, }, }) @@ -74,19 +67,9 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) { const activeJob = task.claude_jobs[0] ?? null - // PBI-47 (P0): require base_sha so diff is scoped to this job's work, - // not the full origin/main...HEAD which would include sibling commits - // on a reused story/sprint branch. - if (activeJob && !activeJob.base_sha) { - return toolError( - 'MISSING_BASE_SHA: This claim has no base_sha. ' - + 'Re-claim the task (cancel + wait_for_job) so a fresh base_sha is captured.', - ) - } - let diff: string try { - diff = await getDiffInWorktree(worktree_path, activeJob?.base_sha ?? undefined) + diff = await getDiffInWorktree(worktree_path) } catch (err) { return toolError( `git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`, diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index f3e11c0..2cb6621 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -7,18 +7,10 @@ import { Client } from 'pg' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import { prisma } from '../prisma.js' - -const execFileP = promisify(execFile) import { requireWriteAccess } from '../auth.js' import { toolJson, toolError, withToolErrors } from '../errors.js' import { createWorktreeForJob } from '../git/worktree.js' -import { getWorktreeRoot } from '../git/worktree-paths.js' -import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js' -import { pushBranchForJob } from '../git/push.js' -import { resolveJobConfig } from '../lib/job-config.js' /** Parse `https://github.com//(.git)?` → ``. */ export function repoNameFromUrl(repoUrl: string | null | undefined): string | null { @@ -119,35 +111,6 @@ export async function resolveBranchForJob( jobId: string, storyId: string, ): Promise<{ branchName: string; reused: boolean }> { - // Sprint-flow (PBI-46): als deze job aan een SprintRun hangt, kies de branch - // op basis van Product.pr_strategy: - // SPRINT → feat/sprint- (één branch voor hele run) - // STORY → feat/story- (één branch per story; sibling-tasks delen 'm) - // Voor legacy task-jobs zonder sprint_run_id valt de logica terug op het - // bestaande feat/story--pad. - const job = await prisma.claudeJob.findUnique({ - where: { id: jobId }, - select: { - sprint_run_id: true, - sprint_run: { select: { id: true, pr_strategy: true } }, - }, - }) - - if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { - const branchName = `feat/sprint-${job.sprint_run.id.slice(-8)}` - const sibling = await prisma.claudeJob.findFirst({ - where: { - sprint_run_id: job.sprint_run_id, - branch: branchName, - id: { not: jobId }, - }, - orderBy: { created_at: 'asc' }, - select: { branch: true }, - }) - return { branchName, reused: sibling !== null } - } - - // STORY-mode (default) of legacy: branch per story const sibling = await prisma.claudeJob.findFirst({ where: { task: { story_id: storyId }, @@ -189,32 +152,6 @@ export async function attachWorktreeToJob( branchName, reuseBranch: reused, }) - - // PBI-47 (P0): capture base_sha so verify_task_against_plan can diff - // against the claim-time HEAD instead of origin/main. For reused branches - // (siblings already pushed), base_sha = SHA of the worktree HEAD now. - // For fresh branches, base_sha = origin/main HEAD which createWorktreeForJob - // just checked out. - let baseSha: string | null = null - try { - const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }) - baseSha = stdout.trim() - } catch (err) { - console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err) - } - // Persist branch + base_sha. update_job_status (prepareDoneUpdate) - // leest claudeJob.branch om naar de juiste ref te pushen — zonder deze - // update valt 'ie terug op het legacy `feat/job-<8>` patroon en faalt - // de push met "src refspec ... does not match any" voor sprint/story - // strategy branches. - await prisma.claudeJob.update({ - where: { id: jobId }, - data: { - branch: actualBranch, - ...(baseSha ? { base_sha: baseSha } : {}), - }, - }) - return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused } } catch (err) { await rollbackClaim(jobId) @@ -234,96 +171,40 @@ const inputSchema = z.object({ const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts' export async function resetStaleClaimedJobs(userId: string): Promise { - // PBI-50: lease-driven stale-detection. Jobs in CLAIMED of RUNNING met - // verlopen lease_until (default 5 min, verlengd door job_heartbeat) worden - // gereset. Legacy jobs zonder lease_until vallen terug op de oude - // claimed_at + 30-min-regel. - type StaleRow = { - id: string - task_id: string | null - product_id: string - kind: string - sprint_run_id: string | null - branch: string | null - } - - const failedRows = await prisma.$queryRaw` + // Jobs that exceeded the retry limit → FAILED + const failedRows = await prisma.$queryRaw< + Array<{ id: string; task_id: string; product_id: string }> + >` UPDATE claude_jobs SET status = 'FAILED', finished_at = NOW(), error = ${STALE_ERROR_MSG} WHERE user_id = ${userId} - AND status IN ('CLAIMED', 'RUNNING') + AND status = 'CLAIMED' + AND claimed_at < NOW() - INTERVAL '30 minutes' AND retry_count >= 2 - AND ( - lease_until < NOW() - OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes') - ) - RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch + RETURNING id, task_id, product_id ` + // Jobs under the retry limit → back to QUEUED, increment retry_count const requeuedRows = await prisma.$queryRaw< - (StaleRow & { retry_count: number })[] + Array<{ id: string; task_id: string; product_id: string; retry_count: number }> >` UPDATE claude_jobs SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL, - lease_until = NULL, retry_count = retry_count + 1 WHERE user_id = ${userId} - AND status IN ('CLAIMED', 'RUNNING') + AND status = 'CLAIMED' + AND claimed_at < NOW() - INTERVAL '30 minutes' AND retry_count < 2 - AND ( - lease_until < NOW() - OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes') - ) - RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch, retry_count + RETURNING id, task_id, product_id, retry_count ` if (failedRows.length === 0 && requeuedRows.length === 0) return - // PBI-9: release any product-worktree locks held by these stale jobs. - for (const j of failedRows) await releaseLocksOnTerminal(j.id) - for (const j of requeuedRows) await releaseLocksOnTerminal(j.id) - - // PBI-50: voor stale FAILED SPRINT_IMPLEMENTATION jobs — push de branch - // zodat het werk niet verloren gaat (geen mark-ready / PR-promotie), - // en zet SprintRun.failure_reason met een verwijzing naar de laatst - // RUNNING execution voor diagnose. - for (const j of failedRows.filter((r) => r.kind === 'SPRINT_IMPLEMENTATION')) { - if (j.branch && j.product_id) { - const repoRoot = await resolveRepoRoot(j.product_id).catch(() => null) - if (repoRoot) { - const worktreeDir = getWorktreeRoot() - const worktreePath = path.join(worktreeDir, j.id) - try { - await pushBranchForJob({ worktreePath, branchName: j.branch }) - } catch (err) { - console.warn(`[stale-reset] push failed for stale sprint-job ${j.id}:`, err) - } - } - } - if (j.sprint_run_id) { - const lastRunning = await prisma.sprintTaskExecution.findFirst({ - where: { sprint_job_id: j.id, status: 'RUNNING' }, - orderBy: { order: 'desc' }, - select: { order: true, task_id: true }, - }) - const reasonSuffix = lastRunning - ? `, last execution: order ${lastRunning.order} task ${lastRunning.task_id}` - : '' - await prisma.sprintRun.update({ - where: { id: j.sprint_run_id }, - data: { - status: 'FAILED', - failure_reason: `stale: lease verlopen${reasonSuffix}`, - }, - }) - } - } - // Notify UI via SSE for each transition (best-effort) try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) @@ -368,15 +249,12 @@ export async function tryClaimJob( ): Promise { // Atomic claim in a single transaction — also captures plan_snapshot from task. // - // PBI-50: claim-filter discrimineert via cj.kind: - // - IDEA_GRILL/IDEA_MAKE_PLAN/PLAN_CHAT: standalone idea-jobs. - // - TASK_IMPLEMENTATION/SPRINT_IMPLEMENTATION: alleen via actieve SprintRun - // (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id en - // jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen. + // Sprint-flow filter (PBI-46): + // Idea-jobs (task_id IS NULL) blijven onafhankelijk claimable. + // Task-jobs zijn alleen claimable wanneer ze aan een actieve SprintRun + // hangen (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id + // en jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen. // Bij eerste claim van een nog QUEUED SprintRun → status RUNNING. - // - // PBI-50 lease: lease_until = NOW() + 5min op claim. resetStaleClaimedJobs - // reset bij verlopen lease. const rows = await prisma.$transaction(async (tx) => { const found = productId ? await tx.$queryRaw< @@ -390,10 +268,8 @@ export async function tryClaimJob( AND cj.product_id = ${productId} AND cj.status = 'QUEUED' AND ( - cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT') - OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION') - AND cj.sprint_run_id IS NOT NULL - AND sr.status IN ('QUEUED', 'RUNNING')) + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -409,10 +285,8 @@ export async function tryClaimJob( WHERE cj.user_id = ${userId} AND cj.status = 'QUEUED' AND ( - cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT') - OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION') - AND cj.sprint_run_id IS NOT NULL - AND sr.status IN ('QUEUED', 'RUNNING')) + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -429,8 +303,7 @@ export async function tryClaimJob( SET status = 'CLAIMED', claimed_by_token_id = ${tokenId}, claimed_at = NOW(), - plan_snapshot = ${snapshot}, - lease_until = NOW() + INTERVAL '5 minutes' + plan_snapshot = ${snapshot} WHERE id = ${jobId} ` @@ -452,7 +325,7 @@ export async function tryClaimJob( return rows.length > 0 ? rows[0].id : null } -export async function getFullJobContext(jobId: string) { +async function getFullJobContext(jobId: string) { const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, include: { @@ -469,87 +342,23 @@ export async function getFullJobContext(jobId: string) { idea: { include: { pbi: { select: { id: true, code: true, title: true } }, - secondary_products: { - include: { product: { select: { id: true, repo_url: true } } }, - }, - }, - }, - product: { - select: { - id: true, - name: true, - repo_url: true, - definition_of_done: true, - preferred_model: true, - thinking_budget_default: true, - preferred_permission_mode: true, }, }, + product: { select: { id: true, name: true, repo_url: true, definition_of_done: true } }, }, }) if (!job) return null - // PBI-67: model + mode-selectie. Resolved op claim-moment; override-cascade - // task.requires_opus → job.requested_* → product.preferred_* → kind-default. - const config = resolveJobConfig( - { - kind: job.kind, - requested_model: job.requested_model, - requested_thinking_budget: job.requested_thinking_budget, - requested_permission_mode: job.requested_permission_mode, - }, - { - preferred_model: job.product.preferred_model, - 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, - ) - // M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze // hebben in plaats daarvan idea + embedded prompt_text. - if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') { + if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { if (!job.idea) return null const { idea } = job - const { getIdeaPromptText } = await import('../lib/kind-prompts.js') - - // Setup persistent product-worktrees for this idea-job (PBI-9). - // Primary product is gated by repo_url via resolveRepoRoot returning null. - // Secondary products from IdeaProduct[] need explicit repo_url filter. - const involvedProductIds: string[] = [] - if (idea.product_id) involvedProductIds.push(idea.product_id) - for (const ip of idea.secondary_products ?? []) { - if (ip.product?.repo_url && !involvedProductIds.includes(ip.product_id)) { - involvedProductIds.push(ip.product_id) - } - } - // PBI-49 P1: rollback the claim if worktree setup fails so the job - // doesn't hang in CLAIMED until the 30-min stale-reset, and any partial - // locks are released. Mirrors attachWorktreeToJob's task-pad behaviour. - let worktrees: Array<{ productId: string; worktreePath: string }> = [] - if (involvedProductIds.length > 0) { - try { - worktrees = await setupProductWorktrees( - job.id, - involvedProductIds, - (pid) => resolveRepoRoot(pid), - ) - } catch (err) { - console.warn( - `[wait-for-job] product-worktree setup failed for idea-job ${job.id}; rolling back claim:`, - err, - ) - await releaseLocksOnTerminal(job.id) - await rollbackClaim(job.id) - return null - } - } - + const { getIdeaPromptText } = await import('../lib/idea-prompts.js') return { job_id: job.id, kind: job.kind, status: 'claimed', - config, idea: { id: idea.id, code: idea.code, @@ -569,188 +378,7 @@ export async function getFullJobContext(jobId: string) { pbi: idea.pbi, repo_url: job.product.repo_url, prompt_text: getIdeaPromptText(job.kind), - branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => { - if (job.kind === 'IDEA_GRILL') return 'grill' - if (job.kind === 'IDEA_REVIEW_PLAN') return 'review' - return 'plan' - })()}`, - product_worktrees: worktrees.map((w) => ({ - product_id: w.productId, - worktree_path: w.worktreePath, - })), - primary_worktree_path: worktrees[0]?.worktreePath ?? null, - } - } - - // PBI-50: SPRINT_IMPLEMENTATION — single-session sprint runner. - // Eén ClaudeJob per SprintRun handelt sequentieel alle TO_DO-tasks af. - // Bij claim: maak frozen scope-snapshot via SprintTaskExecution-rows, - // resolve worktree (verse branch of hergebruikt via previous_run_id), - // capture base_sha. Worker werkt uitsluitend op deze frozen snapshot. - if (job.kind === 'SPRINT_IMPLEMENTATION') { - if (!job.sprint_run_id) { - await rollbackClaim(job.id) - return null - } - const sprintRun = await prisma.sprintRun.findUnique({ - where: { id: job.sprint_run_id }, - include: { - sprint: { - include: { - product: true, - stories: { - where: { status: { not: 'DONE' } }, - include: { - pbi: { - select: { id: true, code: true, title: true, priority: true, sort_order: true, status: true }, - }, - tasks: { - where: { status: 'TO_DO' }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }, - }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }, - }, - }, - }, - }) - if (!sprintRun) { - await rollbackClaim(job.id) - return null - } - - const repoRoot = await resolveRepoRoot(sprintRun.sprint.product_id) - if (!repoRoot) { - await rollbackClaim(job.id) - return null - } - - // Branch resolution: previous_run_id + branch → reuse; anders verse. - const isResume = !!(sprintRun.previous_run_id && sprintRun.branch) - const branchName = isResume - ? sprintRun.branch! - : `feat/sprint-${job.sprint_run_id.slice(-8)}` - - let worktreePath: string - let baseSha: string - try { - const wt = await createWorktreeForJob({ - repoRoot, - jobId: job.id, - branchName, - reuseBranch: isResume, - }) - worktreePath = wt.worktreePath - - const { stdout: headSha } = await execFileP('git', ['rev-parse', 'HEAD'], { - cwd: worktreePath, - }) - baseSha = headSha.trim() - } catch (err) { - console.warn(`[wait-for-job] sprint-worktree setup failed for ${job.id}:`, err) - await rollbackClaim(job.id) - return null - } - - // Verzamel ordered tasks in flat list, behoud volgorde - const orderedTasks = sprintRun.sprint.stories.flatMap((s) => - s.tasks.map((t) => ({ ...t, story_pbi_id: s.pbi.id })), - ) - - // Persist branch + base_sha + scope-snapshot in één transactie - await prisma.$transaction([ - prisma.claudeJob.update({ - where: { id: job.id }, - data: { branch: branchName, base_sha: baseSha }, - }), - prisma.sprintTaskExecution.createMany({ - data: orderedTasks.map((t, idx) => ({ - sprint_job_id: job.id, - task_id: t.id, - order: idx, - plan_snapshot: t.implementation_plan ?? '', - verify_required_snapshot: t.verify_required, - verify_only_snapshot: t.verify_only, - base_sha: idx === 0 ? baseSha : null, - status: 'PENDING' as const, - })), - }), - prisma.sprintRun.update({ - where: { id: job.sprint_run_id }, - data: { branch: branchName }, - }), - ]) - - // Lookup execution_ids in volgorde voor de response - const executions = await prisma.sprintTaskExecution.findMany({ - where: { sprint_job_id: job.id }, - orderBy: { order: 'asc' }, - select: { id: true, task_id: true, order: true, base_sha: true }, - }) - const execIdByTaskId = new Map(executions.map((e) => [e.task_id, e.id])) - - // Dedupe PBIs uit de stories (één PBI kan meerdere stories hebben) - const pbiMap = new Map() - for (const s of sprintRun.sprint.stories) pbiMap.set(s.pbi.id, s.pbi) - - return { - job_id: job.id, - kind: job.kind, - status: 'claimed', - config, - sprint: { - id: sprintRun.sprint.id, - sprint_goal: sprintRun.sprint.sprint_goal, - status: sprintRun.sprint.status, - }, - sprint_run: { - id: sprintRun.id, - pr_strategy: sprintRun.pr_strategy, - branch: branchName, - previous_run_id: sprintRun.previous_run_id, - }, - product: { - id: sprintRun.sprint.product.id, - name: sprintRun.sprint.product.name, - repo_url: sprintRun.sprint.product.repo_url, - definition_of_done: sprintRun.sprint.product.definition_of_done, - auto_pr: sprintRun.sprint.product.auto_pr, - }, - pbis: Array.from(pbiMap.values()).map((p) => ({ - id: p.id, - code: p.code, - title: p.title, - priority: p.priority, - sort_order: p.sort_order, - status: p.status, - })), - stories: sprintRun.sprint.stories.map((s) => ({ - id: s.id, - code: s.code, - title: s.title, - pbi_id: s.pbi_id, - priority: s.priority, - sort_order: s.sort_order, - status: s.status, - })), - task_executions: orderedTasks.map((t, idx) => ({ - execution_id: execIdByTaskId.get(t.id)!, - task_id: t.id, - code: t.code, - title: t.title, - story_id: t.story_id, - order: idx, - plan_snapshot: t.implementation_plan ?? '', - verify_required: t.verify_required, - verify_only: t.verify_only, - base_sha: idx === 0 ? baseSha : null, - })), - worktree_path: worktreePath, - branch_name: branchName, - repo_url: sprintRun.sprint.product.repo_url, - base_sha: baseSha, - heartbeat_interval_seconds: 60, + branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`, } } @@ -764,7 +392,6 @@ export async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', - config, task: { id: task.id, title: task.title, diff --git a/src/verify/classify.ts b/src/verify/classify.ts index 429bfe3..3fe99f5 100644 --- a/src/verify/classify.ts +++ b/src/verify/classify.ts @@ -27,7 +27,7 @@ function extractPlanPaths(plan: string): string[] { let m: RegExpExecArray | null while ((m = backtickRe.exec(plan)) !== null) { const p = m[1].trim() - if (looksLikePath(p)) paths.add(p) + if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p) } const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm @@ -38,20 +38,6 @@ function extractPlanPaths(plan: string): string[] { return [...paths] } -// Heuristic: does this backtick-quoted token look like a file path? -// Excludes code-snippets like `data-debug-label="..."`, `foo()`, `
` — -// anything containing operator/quote/bracket chars or an ellipsis is rejected. -// Accepts paths with a slash (multi-segment) or a recognisable file-extension -// suffix (1–6 alphanumeric chars after a final dot, e.g. `.tsx`, `.json`). -function looksLikePath(p: string): boolean { - if (p.length <= 3) return false - if (p.includes(' ')) return false - if (/[="'<>()[\]{};,]/.test(p)) return false - if (/\.{2,}/.test(p)) return false - if (!p.includes('/') && !/\.[a-zA-Z][a-zA-Z0-9]{0,5}$/.test(p)) return false - return true -} - // Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts". function pathMatches(planPath: string, diffPaths: string[]): boolean { const norm = planPath.replace(/\\/g, '/') diff --git a/vendor/scrum4me b/vendor/scrum4me index 7bb252c..77617e8 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff +Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689