Vier nieuwe tools + propagateStatusUpwards uitbreiding:
T1 — verify_sprint_task (src/tools/verify-sprint-task.ts):
Execution-aware verify met frozen plan_snapshot. Input: execution_id +
worktree_path + optionele summary (voor PARTIAL/DIVERGENT-rationale).
Vult base_sha dynamisch voor task[1..N] op basis van vorige DONE-execution's
head_sha. Schrijft verify_result + verify_summary op execution-row.
Returns { result, reasoning, base_sha, allowed_for_done, reason? } —
allowed_for_done via standaard checkVerifyGate met snapshot-velden.
T2 — update_task_execution (src/tools/update-task-execution.ts):
Lifecycle-tool voor SprintTaskExecution: PENDING/RUNNING/DONE/FAILED/SKIPPED
+ base_sha/head_sha/skip_reason. Idempotent. Token-check via
execution.sprint_job.claimed_by_token_id. started_at/finished_at automatisch.
T3 — job_heartbeat (src/tools/job-heartbeat.ts):
Verlengt ClaudeJob.lease_until met 5 min via atomic conditional UPDATE
(token-check + status-check in WHERE). Voor SPRINT-jobs: response bevat
sprint_run_status + sprint_run_pause_reason zodat worker op UI-side cancel
of MERGE_CONFLICT-pause kan breken zonder extra query.
T4 — update_task_status sprint_run_id-arg + token-coupling
(src/tools/update-task-status.ts):
Optionele sprint_run_id-arg voor expliciete cascade. Validaties: SprintRun
bestaat + actief, task in deze sprint, current token heeft een actieve
ClaudeJob in deze run geclaimd (403 anders). Response uitgebreid met
sprint_run_status_change.
T5 — propagateStatusUpwards sprintRunId-param
(src/lib/tasks-status-update.ts):
Optionele sprintRunId-parameter. Resolve-volgorde: expliciete arg →
ClaudeJob.task_id-lookup → Story → Sprint → SprintRun.findFirst({active}).
De derde fallback dekt SPRINT_IMPLEMENTATION (geen task_id-koppeling) én
handmatige task-statuswijzigingen via UI. cancelExceptJobId voor
sibling-cancel; null voor SPRINT-job betekent geen siblings te cancellen.
src/index.ts: drie nieuwe tools geregistreerd.
Tests: 31 files, 243 passing (geen tests voor nieuwe tools nog — F5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
// 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,
|
|
})
|
|
}),
|
|
)
|
|
}
|