Compare commits
No commits in common. "main" and "feat/sprint-worker" have entirely different histories.
main
...
feat/sprin
53 changed files with 315 additions and 4644 deletions
|
|
@ -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="<generate-with: openssl rand -hex 32>"
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,6 +12,3 @@ prisma/generated
|
|||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Claude Code worktrees (per-session, never tracked)
|
||||
.claude/worktrees/
|
||||
|
|
|
|||
|
|
@ -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 <base_sha>...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-<id>`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-<id>`). 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>
|
||||
create: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||
|
||||
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<ReturnType<typeof handleCreateSprint>>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof vi.fn> }
|
||||
sprint: { findUnique: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
create: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||
|
||||
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<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }),
|
||||
)
|
||||
})
|
||||
|
||||
function parseResult(result: Awaited<ReturnType<typeof handleCreateStory>>) {
|
||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||
try { return JSON.parse(text) } catch { return text }
|
||||
}
|
||||
|
||||
function errorText(result: Awaited<ReturnType<typeof handleCreateStory>>): 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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof import('../src/auth.js')>()
|
||||
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<typeof vi.fn>
|
||||
sprintRun: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof vi.fn> }
|
||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockUserOwnsIdea = userOwnsIdea as ReturnType<typeof vi.fn>
|
||||
|
||||
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<ReturnType<typeof handleUpdateIdeaPlanReviewed>>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ 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(), findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -22,7 +22,6 @@ const mockPrisma = prisma as unknown as {
|
|||
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
|
@ -42,10 +41,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
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
|
||||
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||
|
|
@ -64,27 +62,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 +78,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)
|
||||
|
|
@ -131,9 +113,7 @@ describe('maybeCreateAutoPr', () => {
|
|||
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 } },
|
||||
])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' })
|
||||
|
||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||
|
||||
|
|
@ -141,29 +121,6 @@ describe('maybeCreateAutoPr', () => {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>
|
||||
const mockFindUnique = (prisma as unknown as {
|
||||
claudeJob: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}).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)
|
||||
|
|
|
|||
|
|
@ -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<typeof import('../src/tools/wait-for-job.js')>()
|
||||
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<typeof vi.fn>
|
||||
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
|
||||
const mockPrisma = prisma as unknown as {
|
||||
claudeJob: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
count: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof vi.fn> }
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: { count: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const mocked = prisma as unknown as MockedPrisma
|
||||
|
||||
const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.'
|
||||
|
||||
function execRow(overrides: Record<string, unknown>) {
|
||||
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),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||
|
||||
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<ReturnType<typeof handleUpdateSprint>>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof import('../src/auth.js')>()
|
||||
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<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => handler(args),
|
||||
}
|
||||
registerUpdateTaskExecutionTool(server as unknown as McpServer)
|
||||
return server
|
||||
}
|
||||
|
||||
function execRecord(overrides: Record<string, unknown> = {}) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof import('../src/auth.js')>()
|
||||
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<typeof vi.fn>
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
|
||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => 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<string, unknown> = {}) {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string>()` 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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(), findUnique: vi.fn() },
|
||||
product: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
|
@ -21,7 +21,7 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool
|
|||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> }
|
||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ enum TaskStatus {
|
|||
REVIEW
|
||||
DONE
|
||||
FAILED
|
||||
EXCLUDED
|
||||
}
|
||||
|
||||
enum LogType {
|
||||
|
|
@ -71,9 +70,8 @@ enum TestStatus {
|
|||
}
|
||||
|
||||
enum SprintStatus {
|
||||
OPEN
|
||||
CLOSED
|
||||
ARCHIVED
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +87,6 @@ enum SprintRunStatus {
|
|||
enum PrStrategy {
|
||||
SPRINT
|
||||
STORY
|
||||
SPRINT_BATCH
|
||||
}
|
||||
|
||||
enum IdeaStatus {
|
||||
|
|
@ -100,9 +97,6 @@ enum IdeaStatus {
|
|||
PLANNING
|
||||
PLAN_FAILED
|
||||
PLAN_READY
|
||||
REVIEWING_PLAN
|
||||
PLAN_REVIEW_FAILED
|
||||
PLAN_REVIEWED
|
||||
PLANNED
|
||||
}
|
||||
|
||||
|
|
@ -110,17 +104,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 +112,6 @@ enum IdeaLogType {
|
|||
NOTE
|
||||
GRILL_RESULT
|
||||
PLAN_RESULT
|
||||
PLAN_REVIEW_RESULT
|
||||
STATUS_CHANGE
|
||||
JOB_EVENT
|
||||
}
|
||||
|
|
@ -152,7 +135,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 +149,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 +190,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 +284,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,7 +294,6 @@ model Sprint {
|
|||
tasks Task[]
|
||||
sprint_runs SprintRun[]
|
||||
|
||||
@@unique([product_id, code])
|
||||
@@index([product_id, status])
|
||||
@@map("sprints")
|
||||
}
|
||||
|
|
@ -338,9 +314,6 @@ model SprintRun {
|
|||
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[]
|
||||
|
|
@ -367,7 +340,6 @@ model Task {
|
|||
status TaskStatus @default(TO_DO)
|
||||
verify_only Boolean @default(false)
|
||||
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
||||
requires_opus Boolean @default(false)
|
||||
// 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
|
||||
|
|
@ -378,7 +350,6 @@ model Task {
|
|||
claude_questions ClaudeQuestion[]
|
||||
claude_jobs ClaudeJob[]
|
||||
sprint_run_failures SprintRun[] @relation("SprintRunFailedTask")
|
||||
sprint_task_executions SprintTaskExecution[]
|
||||
|
||||
@@unique([product_id, code])
|
||||
@@index([story_id, priority, sort_order])
|
||||
|
|
@ -413,10 +384,6 @@ 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?
|
||||
|
|
@ -425,8 +392,6 @@ model ClaudeJob {
|
|||
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
|
||||
|
||||
|
|
@ -436,41 +401,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
|
||||
|
|
@ -526,8 +459,6 @@ model Idea {
|
|||
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)
|
||||
|
|
@ -638,18 +569,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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ async function runCascade(failedJobId: string): Promise<CascadeOutcome> {
|
|||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
product_id: true,
|
||||
task_id: true,
|
||||
branch: true,
|
||||
|
|
@ -66,8 +65,6 @@ async function runCascade(failedJobId: string): Promise<CascadeOutcome> {
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -197,21 +194,12 @@ async function runCascade(failedJobId: string): Promise<CascadeOutcome> {
|
|||
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -15,19 +15,6 @@ async function branchExists(repoRoot: string, name: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
async function remoteBranchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||
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,
|
||||
|
|
@ -88,27 +75,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,
|
||||
})
|
||||
}
|
||||
return { worktreePath, branchName }
|
||||
}
|
||||
|
||||
|
|
|
|||
15
src/index.ts
15
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
|
||||
|
|
|
|||
32
src/lib/idea-prompts.ts
Normal file
32
src/lib/idea-prompts.ts
Normal file
|
|
@ -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 ''
|
||||
}
|
||||
|
|
@ -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<string, JobConfig> = {
|
||||
// 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'
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Loader voor embedded prompts per ClaudeJob-kind.
|
||||
//
|
||||
// De .md-bestanden in src/prompts/<kind>/ 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<Record<ClaudeJobKind, string>> = {}
|
||||
|
||||
function loadPrompt(rel: string): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
// src/lib/kind-prompts.ts → src/lib → src → src/prompts/<rel>
|
||||
const path = join(here, '..', 'prompts', rel)
|
||||
return readFileSync(path, 'utf8')
|
||||
}
|
||||
|
||||
const KIND_TO_PROMPT_PATH: Partial<Record<ClaudeJobKind, string>> = {
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
export type PushPayload = { title: string; body: string; url: string; tag?: string };
|
||||
|
||||
export async function triggerPush(userId: string, payload: PushPayload): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PropagationResult> {
|
||||
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
|
||||
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 (!resolvedRunId) {
|
||||
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 (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) {
|
||||
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<UpdateTaskStatusResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — <korte titel>
|
||||
# Idee — {korte titel}
|
||||
|
||||
## Scope
|
||||
…
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "<now>",
|
||||
"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: <id>, plan_md: <herziene tekst> })
|
||||
```
|
||||
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": "<origineel plan_md>",
|
||||
"plan_after": "<herzien plan_md na ronde>",
|
||||
"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`).
|
||||
|
|
@ -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' })`.
|
||||
|
|
@ -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-<id>`); 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -6,7 +6,6 @@ const TASK_DB_TO_API = {
|
|||
REVIEW: 'review',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
EXCLUDED: 'excluded',
|
||||
} as const satisfies Record<TaskStatus, string>
|
||||
|
||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||
|
|
@ -15,7 +14,6 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
|||
review: 'REVIEW',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
excluded: 'EXCLUDED',
|
||||
}
|
||||
|
||||
const STORY_DB_TO_API = {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<typeof inputSchema>,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,23 +46,19 @@ 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(
|
||||
export function registerCreateStoryTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'create_story',
|
||||
{
|
||||
pbi_id,
|
||||
title,
|
||||
description,
|
||||
acceptance_criteria,
|
||||
priority,
|
||||
sort_order,
|
||||
sprint_id,
|
||||
}: z.infer<typeof inputSchema>,
|
||||
) {
|
||||
return withToolErrors(async () => {
|
||||
title: 'Create story',
|
||||
description:
|
||||
'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,
|
||||
},
|
||||
async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) =>
|
||||
withToolErrors(async () => {
|
||||
const auth = await requireWriteAccess()
|
||||
|
||||
const pbi = await prisma.pbi.findUnique({
|
||||
|
|
@ -75,21 +70,6 @@ export async function handleCreateStory(
|
|||
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({
|
||||
|
|
@ -108,14 +88,13 @@ export async function handleCreateStory(
|
|||
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',
|
||||
status: 'OPEN',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -126,7 +105,6 @@ export async function handleCreateStory(
|
|||
priority: true,
|
||||
sort_order: true,
|
||||
status: true,
|
||||
sprint_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
|
|
@ -137,18 +115,6 @@ export async function handleCreateStory(
|
|||
}
|
||||
}
|
||||
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.',
|
||||
inputSchema,
|
||||
},
|
||||
handleCreateStory,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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<typeof inputSchema>,
|
||||
) {
|
||||
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<string, any>,
|
||||
): {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -24,7 +18,6 @@ import { pushBranchForJob } from '../git/push.js'
|
|||
import { createPullRequest, markPullRequestReady } 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'
|
||||
|
|
@ -45,7 +38,7 @@ async function fetchConflictFiles(prUrl: string): Promise<string[]> {
|
|||
|
||||
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 +47,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<void> {
|
||||
const repoRoot = await resolveRepoRoot(productId)
|
||||
|
|
@ -71,57 +63,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-<id>.
|
||||
// - STORY pr_strategy / legacy → alle TASK_IMPLEMENTATION jobs in
|
||||
// dezelfde story delen feat/story-<id>.
|
||||
// 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 ${scope}`,
|
||||
`[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in story ${job.task.story_id}`,
|
||||
)
|
||||
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) {
|
||||
|
|
@ -145,25 +111,9 @@ export async function prepareDoneUpdate(
|
|||
jobId: string,
|
||||
branch: string | undefined,
|
||||
): Promise<DoneUpdatePlan> {
|
||||
// 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-<id> voor SPRINT, feat/story-<id>
|
||||
// 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 worktreePath = path.join(worktreeDir, jobId)
|
||||
const branchName = branch ?? `feat/job-${jobId.slice(-8)}`
|
||||
|
||||
const pushResult = await pushBranchForJob({ worktreePath, branchName })
|
||||
|
||||
|
|
@ -275,147 +225,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<void> {
|
||||
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
|
||||
|
|
@ -446,35 +269,24 @@ export async function maybeCreateAutoPr(opts: {
|
|||
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).
|
||||
// PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun.
|
||||
// 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({
|
||||
const sprintSibling = await prisma.claudeJob.findFirst({
|
||||
where: {
|
||||
sprint_run_id: job.sprint_run_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 sameRepoSibling = sprintSiblings.find(
|
||||
(s) => (s.task?.repo_url ?? null) === thisRepoKey,
|
||||
)
|
||||
if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url
|
||||
if (sprintSibling?.pr_url) return sprintSibling.pr_url
|
||||
|
||||
// Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge.
|
||||
const goal = job.sprint_run.sprint.sprint_goal
|
||||
|
|
@ -496,21 +308,17 @@ export async function maybeCreateAutoPr(opts: {
|
|||
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({
|
||||
// STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR.
|
||||
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
|
||||
|
||||
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
|
||||
const body = summary
|
||||
|
|
@ -524,68 +332,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<string | null> {
|
||||
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 +339,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 +364,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 +374,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,23 +402,6 @@ 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
|
||||
|
|
@ -701,19 +419,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,
|
||||
|
|
@ -735,7 +440,6 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
|
||||
// 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' &&
|
||||
|
|
@ -756,23 +460,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,11 +468,8 @@ 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 } : {}),
|
||||
|
|
@ -797,7 +481,6 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
...(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 +493,6 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
error: true,
|
||||
started_at: true,
|
||||
finished_at: true,
|
||||
head_sha: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -979,9 +661,13 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
try {
|
||||
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
||||
await pg.connect()
|
||||
const notifyPayload: Record<string, unknown> = {
|
||||
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,
|
||||
|
|
@ -991,33 +677,16 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
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.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,91 +694,13 @@ 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') {
|
||||
if (actualStatus === 'done' || actualStatus === 'failed') {
|
||||
await releaseLocksOnTerminal(job_id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SprintStatus>(['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<typeof inputSchema>,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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`,
|
||||
const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion(
|
||||
task_id,
|
||||
dbStatus,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return toolJson({
|
||||
id: task.id,
|
||||
status: taskStatusToApi(task.status),
|
||||
implementation_plan: task.implementation_plan,
|
||||
story_status_change: storyStatusChange,
|
||||
sprint_run_status_change: sprintRunStatusChange,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 <base_sha>...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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -15,10 +15,7 @@ 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/<owner>/<name>(.git)?` → `<name>`. */
|
||||
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
|
||||
|
|
@ -202,18 +199,12 @@ export async function attachWorktreeToJob(
|
|||
} 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.
|
||||
if (baseSha) {
|
||||
await prisma.claudeJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
branch: actualBranch,
|
||||
...(baseSha ? { base_sha: baseSha } : {}),
|
||||
},
|
||||
data: { base_sha: baseSha },
|
||||
})
|
||||
}
|
||||
|
||||
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
|
||||
} catch (err) {
|
||||
|
|
@ -234,96 +225,45 @@ const inputSchema = z.object({
|
|||
const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
|
||||
|
||||
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
|
||||
// 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<StaleRow[]>`
|
||||
// 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.
|
||||
// No-op for jobs without registered locks (TASK_IMPLEMENTATION).
|
||||
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 +308,12 @@ export async function tryClaimJob(
|
|||
): Promise<string | null> {
|
||||
// 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 +327,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 +344,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 +362,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 +384,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: {
|
||||
|
|
@ -474,44 +406,17 @@ export async function getFullJobContext(jobId: string) {
|
|||
},
|
||||
},
|
||||
},
|
||||
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')
|
||||
const { getIdeaPromptText } = await import('../lib/idea-prompts.js')
|
||||
|
||||
// Setup persistent product-worktrees for this idea-job (PBI-9).
|
||||
// Primary product is gated by repo_url via resolveRepoRoot returning null.
|
||||
|
|
@ -549,7 +454,6 @@ export async function getFullJobContext(jobId: string) {
|
|||
job_id: job.id,
|
||||
kind: job.kind,
|
||||
status: 'claimed',
|
||||
config,
|
||||
idea: {
|
||||
id: idea.id,
|
||||
code: idea.code,
|
||||
|
|
@ -569,11 +473,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'
|
||||
})()}`,
|
||||
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`,
|
||||
product_worktrees: worktrees.map((w) => ({
|
||||
product_id: w.productId,
|
||||
worktree_path: w.worktreePath,
|
||||
|
|
@ -582,178 +482,6 @@ export async function getFullJobContext(jobId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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<string, typeof sprintRun.sprint.stories[number]['pbi']>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast.
|
||||
const { task } = job
|
||||
if (!task) return null
|
||||
|
|
@ -764,7 +492,6 @@ export async function getFullJobContext(jobId: string) {
|
|||
job_id: job.id,
|
||||
kind: job.kind,
|
||||
status: 'claimed',
|
||||
config,
|
||||
task: {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
|
|
|
|||
|
|
@ -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()`, `<div>` —
|
||||
// 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, '/')
|
||||
|
|
|
|||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff
|
||||
Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689
|
||||
Loading…
Add table
Add a link
Reference in a new issue