PBI-50: SPRINT_IMPLEMENTATION single-session sprint runner (Scrum4Me-side) (#139)

* PBI-50 F1: SPRINT_BATCH execution-strategy + cross-repo blocker + branch-resume

Schema-migratie + Scrum4Me-side wiring voor de nieuwe SPRINT_IMPLEMENTATION-flow:

- prisma: PrStrategy ADD VALUE 'SPRINT_BATCH'; ClaudeJobKind ADD VALUE
  'SPRINT_IMPLEMENTATION'; nieuwe enum SprintTaskExecutionStatus; ClaudeJob.lease_until
  + status_lease_until index; SprintRun.previous_run_id (self-relation
  SprintRunChain) voor branch-hergebruik bij resume; nieuwe sprint_task_executions
  tabel met frozen plan_snapshot + verify_required_snapshot per task in scope.
- actions/sprint-runs.ts startSprintRunCore: nieuwe blocker-type 'task_cross_repo'
  voor SPRINT_BATCH (pre-flight rejecteert sprints met cross-repo task_url).
  Bij SPRINT_BATCH: één SPRINT_IMPLEMENTATION ClaudeJob (geen per-task loop).
- actions/sprint-runs.ts resumePausedSprintRunAction: SPRINT_BATCH-pad met
  remaining-execution-check; bij onafgemaakt werk → nieuwe SprintRun met
  previous_run_id + run.branch hergebruikt + nieuwe SPRINT_IMPLEMENTATION-job.
  Oude SprintRun → CANCELLED. Bestaande PBI-49 P0 scope-DONE pad ongewijzigd.
- actions/products.ts updatePrStrategyAction: accepteert SPRINT_BATCH.
- components/products/pr-strategy-select.tsx: drie opties met helptekst,
  gebruikt @prisma/client PrStrategy ipv lokaal type.
- components/sprint/sprint-run-controls.tsx: BLOCKER_LABELS + blockerHref
  voor task_cross_repo.

Migratie applied op Neon. Type-check + 532 tests groen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PBI-50 F5: cross-repo blocker test voor SPRINT_BATCH

- task_cross_repo blocker fires bij task.repo_url ≠ product.repo_url
- happy path: tasks zonder repo_url-override of met match → één
  SPRINT_IMPLEMENTATION-job (niet per-task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PBI-50 F5: docs/architecture/sprint-execution-modes.md

Vergelijking PER_TASK vs SPRINT_BATCH met trade-offs, datamodel-
toevoegingen (SprintTaskExecution, lease_until, SprintRunChain) en
MCP-tools-matrix per modus. Toegevoegd aan breadcrumb in
docs/architecture.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-07 13:05:02 +02:00 committed by GitHub
parent e6dcc91383
commit 07749ad9fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 490 additions and 69 deletions

View file

@ -399,14 +399,14 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) {
export async function updatePrStrategyAction(
id: string,
pr_strategy: 'SPRINT' | 'STORY',
pr_strategy: 'SPRINT' | 'STORY' | 'SPRINT_BATCH',
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = z
.object({ pr_strategy: z.enum(['SPRINT', 'STORY']) })
.object({ pr_strategy: z.enum(['SPRINT', 'STORY', 'SPRINT_BATCH']) })
.safeParse({ pr_strategy })
if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' }

View file

@ -13,7 +13,11 @@ async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export type PreFlightBlockerType = 'task_no_plan' | 'open_question' | 'pbi_blocked'
export type PreFlightBlockerType =
| 'task_no_plan'
| 'open_question'
| 'pbi_blocked'
| 'task_cross_repo'
export interface PreFlightBlocker {
type: PreFlightBlockerType
@ -122,6 +126,23 @@ async function startSprintRunCore(
}
}
// PBI-50: SPRINT_BATCH cross-repo blocker. Eén product-worktree =
// alle tasks moeten in product.repo_url werken; task.repo_url-override
// is incompatibel met deze flow.
if (sprint.product.pr_strategy === 'SPRINT_BATCH') {
for (const s of stories) {
for (const t of s.tasks) {
if (t.repo_url && t.repo_url !== sprint.product.repo_url) {
blockers.push({
type: 'task_cross_repo',
id: t.id,
label: `${t.code}: ${t.title}`,
})
}
}
}
}
if (blockers.length > 0) {
return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers }
}
@ -147,6 +168,26 @@ async function startSprintRunCore(
)
.flatMap((s) => s.tasks)
// PBI-50: SPRINT_BATCH levert één SPRINT_IMPLEMENTATION-job die alle
// tasks in één claude-sessie afhandelt. SprintTaskExecution-rows worden
// server-side bij claim aangemaakt zodat order/base_sha consistent zijn
// met de worktree-state op claim-tijd.
if (sprint.product.pr_strategy === 'SPRINT_BATCH') {
await tx.claudeJob.create({
data: {
user_id,
product_id: sprint.product_id,
task_id: null,
idea_id: null,
sprint_run_id: sprintRun.id,
kind: 'SPRINT_IMPLEMENTATION',
status: 'QUEUED',
},
})
return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 }
}
// STORY / SPRINT (per-task): bestaand pad.
for (const t of orderedTasks) {
await tx.claudeJob.create({
data: {
@ -263,10 +304,18 @@ export async function resumePausedSprintRunAction(
const sprint_run_id = parsed.data.sprint_run_id
const userId = session.userId
const result = await prisma.$transaction(async (tx) => {
const run = await tx.sprintRun.findUnique({
where: { id: sprint_run_id },
select: { id: true, status: true, sprint_id: true, pause_context: true },
select: {
id: true,
status: true,
sprint_id: true,
pr_strategy: true,
branch: true,
pause_context: true,
},
})
if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 }
if (run.status !== 'PAUSED')
@ -280,6 +329,57 @@ export async function resumePausedSprintRunAction(
})
}
// PBI-50: SPRINT_BATCH resume-pad — als de SprintRun hangt aan een
// SPRINT_IMPLEMENTATION-job en er nog onafgemaakte SprintTaskExecution-rows
// zijn (PENDING/RUNNING), maak NIEUWE SprintRun met previous_run_id +
// hergebruikte branch + nieuwe SPRINT_IMPLEMENTATION-job. Oude SprintRun
// gaat naar CANCELLED.
const sprintJob = await tx.claudeJob.findFirst({
where: { sprint_run_id, kind: 'SPRINT_IMPLEMENTATION' },
select: { id: true, product_id: true },
})
if (sprintJob) {
const remaining = await tx.sprintTaskExecution.count({
where: {
sprint_job_id: sprintJob.id,
status: { in: ['PENDING', 'RUNNING'] },
},
})
if (remaining > 0) {
const newRun = await tx.sprintRun.create({
data: {
sprint_id: run.sprint_id,
started_by_id: userId,
status: 'QUEUED',
pr_strategy: run.pr_strategy,
branch: run.branch,
previous_run_id: run.id,
started_at: new Date(),
},
})
await tx.claudeJob.create({
data: {
user_id: userId,
product_id: sprintJob.product_id,
task_id: null,
idea_id: null,
sprint_run_id: newRun.id,
kind: 'SPRINT_IMPLEMENTATION',
status: 'QUEUED',
},
})
await tx.sprintRun.update({
where: { id: sprint_run_id },
data: {
status: 'CANCELLED',
pause_context: Prisma.JsonNull,
finished_at: new Date(),
},
})
return { ok: true as const, sprint_id: run.sprint_id, finalStatus: 'QUEUED' as const }
}
}
const activeClaims = await tx.claudeJob.count({
where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } },
})
@ -287,11 +387,10 @@ export async function resumePausedSprintRunAction(
where: { sprint_run_id, status: 'QUEUED' },
})
// PBI-49 P0: a STORY auto-merge MERGE_CONFLICT lands AFTER all tasks are
// already DONE (storyBecameDone fires the conflict). Going back to QUEUED
// would hang the SprintRun forever — there is no QUEUED job to claim.
// When the scope is fully complete, transition straight to DONE; the
// dev resolved the conflict manually and the PR is theirs to merge.
// PBI-49 P0: een STORY auto-merge MERGE_CONFLICT komt NA dat alle tasks
// al DONE zijn. Terug naar QUEUED zou de SprintRun voor altijd laten
// hangen — geen QUEUED job. Bij volledige scope-completion transitie
// direct naar DONE; de dev heeft het conflict opgelost, de PR is van hen.
let nextStatus: 'RUNNING' | 'QUEUED' | 'DONE'
let finishedAt: Date | undefined
if (activeClaims === 0 && queuedJobs === 0) {