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:
parent
f87b20744b
commit
f01fab8c38
7 changed files with 248 additions and 36 deletions
|
|
@ -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 }) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue