From 6ee55e79b6c3cf29381e9368f096efade9d8ac9d Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 11:50:51 +0200 Subject: [PATCH] feat: integrate createWorktreeForJob into wait_for_job tool After claiming a job, resolves repoRoot (env SCRUM4ME_REPO_ROOT_ 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 --- __tests__/wait-for-job-worktree.test.ts | 138 ++++++++++++++++++++++++ src/tools/wait-for-job.ts | 64 ++++++++++- 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 __tests__/wait-for-job-worktree.test.ts diff --git a/__tests__/wait-for-job-worktree.test.ts b/__tests__/wait-for-job-worktree.test.ts new file mode 100644 index 0000000..0f18052 --- /dev/null +++ b/__tests__/wait-for-job-worktree.test.ts @@ -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 } +const mockCreateWorktree = createWorktreeForJob as ReturnType + +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'") + }) +}) diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index d4e5be5..740710b 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -5,9 +5,63 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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 { requireWriteAccess } from '../auth.js' import { toolJson, toolError, withToolErrors } from '../errors.js' +import { createWorktreeForJob } from '../git/worktree.js' + +export async function resolveRepoRoot(productId: string): Promise { + 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 } + return config.repoRoots?.[productId] ?? null + } catch { + return null + } +} + +export async function rollbackClaim(jobId: string): Promise { + 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 POLL_INTERVAL_MS = 5_000 @@ -162,6 +216,8 @@ export function registerWaitForJobTool(server: McpServer) { description: '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). ' + + '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". ' + 'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' + 'Pass optional product_id to scope to a specific product. ' + @@ -199,7 +255,9 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) 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 @@ -243,7 +301,9 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) 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 {