feat(routing): cross-repo task routing + orphan-branch cleanup (#17)
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
2c85f4d239
5 changed files with 88 additions and 16 deletions
|
|
@ -74,11 +74,12 @@ describe('createWorktreeForJob', () => {
|
||||||
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
|
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()
|
const { repoDir, originDir } = await setupRepo()
|
||||||
tmpDirs.push(repoDir, originDir)
|
tmpDirs.push(repoDir, originDir)
|
||||||
await makeWorktreeParent()
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// Pre-create an orphan branch (no worktree attached)
|
||||||
await git(['branch', 'feat/job-002'], repoDir)
|
await git(['branch', 'feat/job-002'], repoDir)
|
||||||
|
|
||||||
const result = await createWorktreeForJob({
|
const result = await createWorktreeForJob({
|
||||||
|
|
@ -88,10 +89,11 @@ describe('createWorktreeForJob', () => {
|
||||||
baseRef: 'origin/main',
|
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)
|
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 () => {
|
it('rejects when worktree path already exists', async () => {
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,11 @@ model Task {
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
verify_only Boolean @default(false)
|
verify_only Boolean @default(false)
|
||||||
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
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())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,30 @@ export async function createWorktreeForJob(opts: {
|
||||||
return { worktreePath, branchName }
|
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)) {
|
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], {
|
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
|
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}`
|
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
|
||||||
if (process.env[envKey]) return process.env[envKey]!
|
if (process.env[envKey]) return process.env[envKey]!
|
||||||
|
|
||||||
|
// 3. Config file per-product
|
||||||
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(configPath, 'utf-8')
|
const raw = await fs.readFile(configPath, 'utf-8')
|
||||||
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
|
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
|
||||||
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
|
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — fall through to convention-based fallback
|
// ignore — fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convention-based fallback: ~/Projects/<repo-name> with .git/ inside.
|
// 4. Convention via product.repo_url
|
||||||
// Lets the agent work without explicit env-config when checkouts follow
|
|
||||||
// the standard ~/Projects/<name> layout.
|
|
||||||
try {
|
try {
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
|
|
@ -90,14 +128,19 @@ export async function attachWorktreeToJob(
|
||||||
productId: string,
|
productId: string,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
storyId: string,
|
storyId: string,
|
||||||
|
taskRepoUrl?: string | null,
|
||||||
): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> {
|
): 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) {
|
if (!repoRoot) {
|
||||||
await rollbackClaim(jobId)
|
await rollbackClaim(jobId)
|
||||||
|
const repoHint = taskRepoUrl
|
||||||
|
? `task.repo_url=${taskRepoUrl}`
|
||||||
|
: `product ${productId}`
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
`No repo root configured for product ${productId}. ` +
|
`No repo root configured for ${repoHint}. ` +
|
||||||
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
|
`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,
|
description: task.description,
|
||||||
implementation_plan: task.implementation_plan,
|
implementation_plan: task.implementation_plan,
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
|
repo_url: task.repo_url,
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
id: story.id,
|
id: story.id,
|
||||||
|
|
@ -334,7 +378,7 @@ export function registerWaitForJobTool(server: McpServer) {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const ctx = await getFullJobContext(jobId)
|
const ctx = await getFullJobContext(jobId)
|
||||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
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)
|
if ('error' in wt) return toolError(wt.error)
|
||||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +416,7 @@ export function registerWaitForJobTool(server: McpServer) {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const ctx = await getFullJobContext(jobId)
|
const ctx = await getFullJobContext(jobId)
|
||||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
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)
|
if ('error' in wt) return toolError(wt.error)
|
||||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
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