feat: integrate createWorktreeForJob into wait_for_job tool
After claiming a job, resolves repoRoot (env SCRUM4ME_REPO_ROOT_<productId> or ~/.scrum4me-agent-config.json), creates a git worktree, and returns worktree_path + branch_name in the response. Rolls back claim to QUEUED on failure. Tool description updated to instruct agent to work in worktree. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b20e297851
commit
6ee55e79b6
2 changed files with 200 additions and 2 deletions
138
__tests__/wait-for-job-worktree.test.ts
Normal file
138
__tests__/wait-for-job-worktree.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import * as fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
$executeRaw: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/git/worktree.js', () => ({
|
||||||
|
createWorktreeForJob: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
|
import { createWorktreeForJob } from '../src/git/worktree.js'
|
||||||
|
import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tools/wait-for-job.js'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as { $executeRaw: ReturnType<typeof vi.fn> }
|
||||||
|
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveRepoRoot', () => {
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore env
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
|
||||||
|
}
|
||||||
|
Object.assign(process.env, originalEnv)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns value from env var when set', async () => {
|
||||||
|
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||||
|
const result = await resolveRepoRoot('prod-001')
|
||||||
|
expect(result).toBe('/repos/my-project')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no env var and no config file', async () => {
|
||||||
|
delete process.env['SCRUM4ME_REPO_ROOT_prod-999']
|
||||||
|
// Config file at home won't have this productId in CI
|
||||||
|
const result = await resolveRepoRoot('prod-999-nonexistent')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads from config file when env var is absent', async () => {
|
||||||
|
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
||||||
|
const config = { repoRoots: { 'prod-config': '/repos/from-config' } }
|
||||||
|
let wroteConfig = false
|
||||||
|
try {
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8')
|
||||||
|
wroteConfig = true
|
||||||
|
delete process.env['SCRUM4ME_REPO_ROOT_prod-config']
|
||||||
|
|
||||||
|
const result = await resolveRepoRoot('prod-config')
|
||||||
|
expect(result).toBe('/repos/from-config')
|
||||||
|
} finally {
|
||||||
|
// Clean up only what we wrote — don't delete if it pre-existed
|
||||||
|
if (wroteConfig) {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(await fs.readFile(configPath, 'utf-8'))
|
||||||
|
delete existing.repoRoots?.['prod-config']
|
||||||
|
if (Object.keys(existing.repoRoots ?? {}).length === 0 && Object.keys(existing).length === 1) {
|
||||||
|
await fs.rm(configPath)
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing), 'utf-8')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await fs.rm(configPath).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('attachWorktreeToJob', () => {
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
|
||||||
|
}
|
||||||
|
Object.assign(process.env, originalEnv)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns worktree_path and branch_name on success', async () => {
|
||||||
|
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||||
|
mockCreateWorktree.mockResolvedValue({
|
||||||
|
worktreePath: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||||
|
branchName: 'feat/job-abc12345',
|
||||||
|
})
|
||||||
|
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const result = await attachWorktreeToJob('prod-001', 'job-abc12345')
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
worktree_path: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||||
|
branch_name: 'feat/job-abc12345',
|
||||||
|
})
|
||||||
|
expect(mockCreateWorktree).toHaveBeenCalledWith({
|
||||||
|
repoRoot: '/repos/my-project',
|
||||||
|
jobId: 'job-abc12345',
|
||||||
|
branchName: 'feat/job-abc12345',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls back claim and returns error when no repoRoot configured', async () => {
|
||||||
|
delete process.env['SCRUM4ME_REPO_ROOT_prod-no-root']
|
||||||
|
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const result = await attachWorktreeToJob('prod-no-root', 'job-xyz')
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
expect((result as { error: string }).error).toContain('No repo root configured')
|
||||||
|
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
|
||||||
|
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
|
||||||
|
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls back claim and returns error when createWorktreeForJob throws', async () => {
|
||||||
|
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||||
|
mockCreateWorktree.mockRejectedValue(new Error('git fetch failed'))
|
||||||
|
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const result = await attachWorktreeToJob('prod-001', 'job-fail')
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
expect((result as { error: string }).error).toContain('git fetch failed')
|
||||||
|
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
|
||||||
|
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
|
||||||
|
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -5,9 +5,63 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { Client } from 'pg'
|
import { Client } from 'pg'
|
||||||
|
import * as fs from 'node:fs/promises'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
||||||
|
import { createWorktreeForJob } from '../git/worktree.js'
|
||||||
|
|
||||||
|
export async function resolveRepoRoot(productId: string): Promise<string | null> {
|
||||||
|
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
|
||||||
|
if (process.env[envKey]) return process.env[envKey]!
|
||||||
|
|
||||||
|
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> }
|
||||||
|
return config.repoRoots?.[productId] ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackClaim(jobId: string): Promise<void> {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE claude_jobs
|
||||||
|
SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL
|
||||||
|
WHERE id = ${jobId}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachWorktreeToJob(
|
||||||
|
productId: string,
|
||||||
|
jobId: string,
|
||||||
|
): Promise<{ worktree_path: string; branch_name: string } | { error: string }> {
|
||||||
|
const repoRoot = await resolveRepoRoot(productId)
|
||||||
|
if (!repoRoot) {
|
||||||
|
await rollbackClaim(jobId)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
`No repo root configured for product ${productId}. ` +
|
||||||
|
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchName = `feat/job-${jobId.slice(-8)}`
|
||||||
|
try {
|
||||||
|
const { worktreePath, branchName: actualBranch } = await createWorktreeForJob({
|
||||||
|
repoRoot,
|
||||||
|
jobId,
|
||||||
|
branchName,
|
||||||
|
})
|
||||||
|
return { worktree_path: worktreePath, branch_name: actualBranch }
|
||||||
|
} catch (err) {
|
||||||
|
await rollbackClaim(jobId)
|
||||||
|
return { error: `Worktree creation failed: ${(err as Error).message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_WAIT_SECONDS = 600
|
const MAX_WAIT_SECONDS = 600
|
||||||
const POLL_INTERVAL_MS = 5_000
|
const POLL_INTERVAL_MS = 5_000
|
||||||
|
|
@ -162,6 +216,8 @@ export function registerWaitForJobTool(server: McpServer) {
|
||||||
description:
|
description:
|
||||||
'Block until a QUEUED ClaudeJob is available for this user, then claim it atomically ' +
|
'Block until a QUEUED ClaudeJob is available for this user, then claim it atomically ' +
|
||||||
'and return full task context (implementation_plan, story, pbi, sprint, repo_url). ' +
|
'and return full task context (implementation_plan, story, pbi, sprint, repo_url). ' +
|
||||||
|
'Also creates a git worktree for the job and returns worktree_path and branch_name. ' +
|
||||||
|
'Work exclusively in worktree_path — do all file edits and commits there. ' +
|
||||||
'Registers worker presence so the Scrum4Me UI can show "Agent verbonden". ' +
|
'Registers worker presence so the Scrum4Me UI can show "Agent verbonden". ' +
|
||||||
'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' +
|
'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' +
|
||||||
'Pass optional product_id to scope to a specific product. ' +
|
'Pass optional product_id to scope to a specific product. ' +
|
||||||
|
|
@ -199,7 +255,9 @@ 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')
|
||||||
return toolJson(ctx)
|
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
|
||||||
|
if ('error' in wt) return toolError(wt.error)
|
||||||
|
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No job available — LISTEN and poll until timeout
|
// 3. No job available — LISTEN and poll until timeout
|
||||||
|
|
@ -243,7 +301,9 @@ 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')
|
||||||
return toolJson(ctx)
|
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
|
||||||
|
if ('error' in wt) return toolError(wt.error)
|
||||||
|
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue