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:
parent
e6dcc91383
commit
07749ad9fb
10 changed files with 490 additions and 69 deletions
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue