feat(routing): cross-repo task routing + orphan-branch cleanup
Two related fixes for the agent-workflow defects exposed by the 2-May-2026 batch: 1. **Cross-repo task routing** (`task.repo_url` override). `resolveRepoRoot` now consults `task.repo_url` first; matches against per-repo env-var (`SCRUM4ME_REPO_ROOT_REPO_<name>`), `~/.scrum4me-agent-config.json` `repoRoots[<name>]`, and finally `~/Projects/<name>/.git`. Falls back to product-level resolution when null. Tasks tracked under one product but targeting another repo (e.g. MCP-server tasks under the main product's PBI) now work. `getFullJobContext` exposes `task.repo_url` to the agent. `attachWorktreeToJob` accepts and forwards it. 2. **Orphan-branch cleanup** in `createWorktreeForJob`. Previously a name-collision suffixed with a timestamp, leaving the agent on an unpredictable `feat/story-XXX-<ms>`-name. Worse, in the 2-May batch the agent ended up reusing an orphan branch from an earlier story (`feat/story-x35n155c`) and pushed to a remote ref that did not exist, causing 'src refspec does not match any'. Now: detect orphan, attempt to remove its (stale) worktree if any, delete the local branch, and recreate with the predictable name. Timestamp-suffix is the last resort. Vendor submodule bumped to pick up `Task.repo_url` from Scrum4Me #54. Tests: 129/129 — `suffixes branch name with timestamp` updated to `removes orphan branch and reuses the predictable name`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1fe6ccf609
commit
4aea8d2baa
5 changed files with 88 additions and 16 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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-<id>`-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], {
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
/**
|
||||
* 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_<productId>`
|
||||
* 3. `~/.scrum4me-agent-config.json` `repoRoots[productId]`
|
||||
* 4. Convention `~/Projects/<repo-name-from-product.repo_url>/.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<string | null> {
|
||||
// 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<string, string> }
|
||||
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<string, string> }
|
||||
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
|
||||
} catch {
|
||||
// ignore — fall through to convention-based fallback
|
||||
// ignore — fall through
|
||||
}
|
||||
|
||||
// Convention-based fallback: ~/Projects/<repo-name> with .git/ inside.
|
||||
// Lets the agent work without explicit env-config when checkouts follow
|
||||
// the standard ~/Projects/<name> 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/<repo-name>.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit e02c6ff9d9eef142cd72011d46f565a10e4b23ac
|
||||
Subproject commit a754acf13ba68c411d73060537ef356037230065
|
||||
Loading…
Add table
Add a link
Reference in a new issue