feat: branch-per-story + worktree-defer + verify EMPTY edge-cases (#12)

Implementeert vier open stories uit PBI 'Veilige Claude-agent-workflow':

**Branch per story (cmon11tbe001zbortx35n155c)**
- `resolveBranchForJob`: zoek sibling-job in dezelfde story; reuse z'n
  branch (1 PR per story i.p.v. per task).
- Branch-naam: `feat/story-<8-char>` voor nieuwe stories.
- `createWorktreeForJob` kent nu `reuseBranch=true`: detecteert stale
  sibling-worktree die de branch nog vasthoudt en verwijdert die eerst.
- `attachWorktreeToJob` neemt `storyId` mee.

**PR-hergebruik (zelfde story)**
- `maybeCreateAutoPr`: als sibling-job in story al een pr_url heeft,
  hergebruik die zonder nieuwe `gh pr create`-call. PR-titel komt nu
  van de story (was task) zodat het als 'story-PR' leest.

**Worktree-cleanup uitgesteld bij actieve siblings**
- `cleanupWorktreeForTerminalStatus`: count active sibling-jobs in
  dezelfde story; defer als > 0 (volgende sub-task gebruikt branch).

**Worktree-cleanup logging (cmon0jc14001ubortjxf2a2ck)**
- Warning bij ontbrekende repoRoot, met productId + jobId in message.
- Warning bij removeWorktreeForJob-failure met keepBranch in message.

**resolveRepoRoot fallback (cmon0jc14001ubortjxf2a2ck)**
- Convention-based fallback: `~/Projects/<repo-name>` afgeleid uit
  `product.repo_url` als noch env-var noch config-bestand iets oplevert.
- `repoNameFromUrl` helper geëxporteerd voor herbruikbaarheid.

**Verify EMPTY-detection edge-case (cmon0kdq6001xbort2kgbcqmr)**
- `classifyDiffAgainstPlan`: na file-paths-check ook content-lines
  checken; als alle +/- regels alleen headers of whitespace zijn,
  return EMPTY met duidelijke reasoning.

Tests: 120/120 groen (3 nieuwe), tsc clean, build clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 17:04:54 +02:00 committed by GitHub
parent f87b20744b
commit f01fab8c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 248 additions and 36 deletions

View file

@ -30,14 +30,45 @@ export async function cleanupWorktreeForTerminalStatus(
branch: string | undefined,
): Promise<void> {
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) return
if (!repoRoot) {
console.warn(
`[update_job_status] cleanup skip for job=${jobId}: no repoRoot configured for product ${productId}`,
)
return
}
// 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 } } },
})
if (job) {
const activeSiblings = await prisma.claudeJob.count({
where: {
task: { story_id: job.task.story_id },
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
id: { not: jobId },
},
})
if (activeSiblings > 0) {
console.log(
`[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 and a branch was reported (agent pushed)
const keepBranch = status === 'done' && branch !== undefined
try {
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
} catch (err) {
console.warn(`[update_job_status] Worktree cleanup failed for job ${jobId}:`, err)
console.warn(
`[update_job_status] cleanup FAILED for job=${jobId} keepBranch=${keepBranch}:`,
err,
)
}
}
@ -144,16 +175,33 @@ export async function maybeCreateAutoPr(opts: {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { title: true, story: { select: { code: true } } },
select: {
title: true,
story: { select: { id: true, code: true, title: true } },
},
})
if (!task) return null
const title = task.story.code ? `${task.story.code}: ${task.title}` : task.title
const body = summary
? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent*`
: '*Auto-generated by Scrum4Me agent*'
// Branch-per-story: if a sibling job in the same story already opened a PR,
// reuse its URL. This avoids one PR per sub-task.
const sibling = await prisma.claudeJob.findFirst({
where: {
task: { story_id: task.story.id },
pr_url: { not: null },
id: { not: jobId },
},
select: { pr_url: true },
orderBy: { created_at: 'asc' },
})
if (sibling?.pr_url) return sibling.pr_url
const result = await createPullRequest({ worktreePath, branchName, title, body })
// First DONE-task in the story → create a story-scoped PR
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
const body = summary
? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent (first task in story; PR-body will accumulate as sibling tasks complete).*`
: '*Auto-generated by Scrum4Me agent (first task in story).*'
const result = await createPullRequest({ worktreePath, branchName, title: storyTitle, body })
if ('url' in result) return result.url
console.warn(`[update_job_status] auto-PR skipped for job ${jobId}:`, result.error)
@ -169,14 +217,10 @@ export function registerUpdateJobStatusTool(server: McpServer) {
'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' +
'running (start), done (finished), failed (error). ' +
'The Bearer token must match the token that claimed the job. ' +
<<<<<<< feat/job-mgskzyvx
'Automatically emits an SSE event so the Scrum4Me UI updates in real time. ' +
'Response includes next_action: when wait_for_job_again, immediately call wait_for_job again. When queue_empty, the agent batch is done.',
=======
'Before marking done: call verify_task_against_plan first — done is rejected when ' +
'verify_result is null or EMPTY (unless task.verify_only is true). ' +
'Automatically emits an SSE event so the Scrum4Me UI updates in real time.',
>>>>>>> main
'Automatically emits an SSE event so the Scrum4Me UI updates in real time. ' +
'Response includes next_action: when wait_for_job_again, immediately call wait_for_job again. When queue_empty, the agent batch is done.',
inputSchema,
},
async ({ job_id, status, branch, summary, error }) =>