PBI-50 F4: SPRINT_IMPLEMENTATION DONE/FAILED-paden + quota-pause

- checkSprintVerifyGate: aggregate verify-gate via SprintTaskExecution.
  Per row: DONE → checkVerifyGate met snapshot-velden, SKIPPED →
  alleen toegestaan bij verify_required=ANY, FAILED/PENDING/RUNNING →
  blocker. Toolerror met opsomming bij faal.
- finalizeSprintRunOnDone: idempotent SprintRun → DONE wanneer alle
  stories DONE/FAILED zijn.
- maybeCreateSprintBatchPr: één draft-PR per sprint met sprint_goal
  als title. Hergebruikt bestaande PR via SprintRunChain bij resume.
- DONE-pad: na update markPullRequestReady wanneer SprintRun DONE.
- FAILED-pad: detect QUOTA_PAUSE: prefix → SprintRun PAUSED met
  pause_context (resume-instructions + last-completed-task); anders
  → FAILED met failure_reason + failed_task_id (uit error-string).
- cancelPbiOnFailure overslaan voor SPRINT-jobs (geen task_id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Madhura68 2026-05-07 12:48:04 +02:00
parent 25ab68073a
commit 876a7ad5d9

View file

@ -225,6 +225,106 @@ 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',
@ -332,6 +432,68 @@ 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',
@ -379,6 +541,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
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 } },
@ -419,6 +582,19 @@ 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,
@ -440,6 +616,7 @@ 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' &&
@ -460,6 +637,23 @@ 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]
@ -493,6 +687,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
error: true,
started_at: true,
finished_at: true,
head_sha: true,
},
})
@ -694,10 +889,88 @@ 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') {