diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts index d92ee00..68f5e19 100644 --- a/__tests__/git/worktree.test.ts +++ b/__tests__/git/worktree.test.ts @@ -74,11 +74,12 @@ describe('createWorktreeForJob', () => { expect(result.worktreePath).toBe(path.join(wtParent, 'job-001')) }) - it('suffixes branch name with timestamp when branch already exists', async () => { + it('removes orphan branch and reuses the predictable name when no worktree owns it', async () => { const { repoDir, originDir } = await setupRepo() tmpDirs.push(repoDir, originDir) await makeWorktreeParent() + // Pre-create an orphan branch (no worktree attached) await git(['branch', 'feat/job-002'], repoDir) const result = await createWorktreeForJob({ @@ -88,10 +89,11 @@ describe('createWorktreeForJob', () => { baseRef: 'origin/main', }) - expect(result.branchName).toMatch(/^feat\/job-002-\d+$/) + // Orphan was deleted → predictable name reused, no timestamp suffix + expect(result.branchName).toBe('feat/job-002') const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) - expect(stdout.trim()).toBe(result.branchName) + expect(stdout.trim()).toBe('feat/job-002') }) it('rejects when worktree path already exists', async () => { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee5beb6..489b23f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -250,6 +250,11 @@ model Task { status TaskStatus @default(TO_DO) verify_only Boolean @default(false) verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) + // Override product.repo_url for branch/worktree/push purposes. Set when + // a task targets a different repo than its parent product (e.g. an + // MCP-server task tracked under the main product's PBI). Falls back to + // product.repo_url when null. + repo_url String? created_at DateTime @default(now()) updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] diff --git a/src/git/worktree.ts b/src/git/worktree.ts index 1a2a7db..0c78a24 100644 --- a/src/git/worktree.ts +++ b/src/git/worktree.ts @@ -81,9 +81,30 @@ export async function createWorktreeForJob(opts: { return { worktreePath, branchName } } - // Fresh branch: suffix with timestamp when name collision occurs + // Fresh branch: if a local branch with this name already exists, it is an + // orphan from a prior failed run (the agent didn't push or branch was + // never tied to a worktree). Remove the orphan so the new worktree gets + // the predictable `feat/story-`-name; this prevents the kind of + // 2-May-2026 failure where the agent inherited an unrelated suffix and + // pushed to a non-existent remote ref. if (await branchExists(repoRoot, branchName)) { - branchName = `${branchName}-${Date.now()}` + const occupant = await findWorktreeForBranch(repoRoot, branchName) + if (occupant) { + // Branch is currently checked out elsewhere — likely a sibling worktree + // that should have been cleaned up. Remove it before reusing the name. + try { + await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot }) + } catch { + // ignore — fall through to deletion below + } + } + try { + await exec('git', ['branch', '-D', branchName], { cwd: repoRoot }) + console.warn(`[createWorktreeForJob] removed orphan branch ${branchName} before recreate`) + } catch { + // last resort: timestamp-suffix to avoid collision rather than fail + branchName = `${branchName}-${Date.now()}` + } } await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index a5b80b0..65972d7 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -19,22 +19,60 @@ export function repoNameFromUrl(repoUrl: string | null | undefined): string | nu return m ? m[1] : null } -export async function resolveRepoRoot(productId: string): Promise { +/** + * Resolve the repo-root path on disk for a job's worktree. + * + * Lookup order (first hit wins): + * 1. `task.repo_url`-override → match against config / convention via repo-name + * 2. env var `SCRUM4ME_REPO_ROOT_` + * 3. `~/.scrum4me-agent-config.json` `repoRoots[productId]` + * 4. Convention `~/Projects//.git` + * + * The task-level override exists for cross-repo tasks (e.g. an MCP-server + * task tracked under the main product's PBI). Falls back to product-level + * resolution when null. Documented in CLAUDE.md. + */ +export async function resolveRepoRoot( + productId: string, + taskRepoUrl?: string | null, +): Promise { + // 1. Task-level override: match by repo-name through config/convention + if (taskRepoUrl) { + const taskRepoName = repoNameFromUrl(taskRepoUrl) + if (taskRepoName) { + const overrideEnv = `SCRUM4ME_REPO_ROOT_REPO_${taskRepoName}` + if (process.env[overrideEnv]) return process.env[overrideEnv]! + + const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json') + try { + const raw = await fs.readFile(configPath, 'utf-8') + const config = JSON.parse(raw) as { repoRoots?: Record } + if (config.repoRoots?.[taskRepoName]) return config.repoRoots[taskRepoName] + } catch { /* fall through */ } + + const candidate = path.join(os.homedir(), 'Projects', taskRepoName) + try { + await fs.access(path.join(candidate, '.git')) + return candidate + } catch { /* fall through to product-level */ } + } + } + + // 2. Env var per-product const envKey = `SCRUM4ME_REPO_ROOT_${productId}` if (process.env[envKey]) return process.env[envKey]! + // 3. Config file per-product const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json') try { const raw = await fs.readFile(configPath, 'utf-8') const config = JSON.parse(raw) as { repoRoots?: Record } if (config.repoRoots?.[productId]) return config.repoRoots[productId] } catch { - // ignore — fall through to convention-based fallback + // ignore — fall through } - // Convention-based fallback: ~/Projects/ with .git/ inside. - // Lets the agent work without explicit env-config when checkouts follow - // the standard ~/Projects/ layout. + // 4. Convention via product.repo_url try { const product = await prisma.product.findUnique({ where: { id: productId }, @@ -90,14 +128,19 @@ export async function attachWorktreeToJob( productId: string, jobId: string, storyId: string, + taskRepoUrl?: string | null, ): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> { - const repoRoot = await resolveRepoRoot(productId) + const repoRoot = await resolveRepoRoot(productId, taskRepoUrl) if (!repoRoot) { await rollbackClaim(jobId) + const repoHint = taskRepoUrl + ? `task.repo_url=${taskRepoUrl}` + : `product ${productId}` return { error: - `No repo root configured for product ${productId}. ` + - `Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`, + `No repo root configured for ${repoHint}. ` + + `Set env var SCRUM4ME_REPO_ROOT_${productId}, add a repoRoots entry to ~/.scrum4me-agent-config.json, ` + + `or place a clone at ~/Projects/.`, } } @@ -280,6 +323,7 @@ async function getFullJobContext(jobId: string) { description: task.description, implementation_plan: task.implementation_plan, priority: task.priority, + repo_url: task.repo_url, }, story: { id: story.id, @@ -334,7 +378,7 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) if (!ctx) return toolError('Job claimed but context fetch failed') - const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id) + const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url) if ('error' in wt) return toolError(wt.error) return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) } @@ -372,7 +416,7 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) if (!ctx) return toolError('Job claimed but context fetch failed') - const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id) + const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url) if ('error' in wt) return toolError(wt.error) return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) } diff --git a/vendor/scrum4me b/vendor/scrum4me index e02c6ff..a754acf 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit e02c6ff9d9eef142cd72011d46f565a10e4b23ac +Subproject commit a754acf13ba68c411d73060537ef356037230065