diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..af9950a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md — scrum4me-mcp + +MCP server that exposes the Scrum4Me dev-flow as native tools for Claude Code. + +## Agent worktree-flow + +`wait_for_job` creates an isolated git worktree per job so agent changes never touch the user's main checkout. + +### How it works + +1. On successful claim, `wait_for_job` calls `createWorktreeForJob`: + - Worktree directory: `SCRUM4ME_AGENT_WORKTREE_DIR/` (default: `~/.scrum4me-agent-worktrees/`) + - Branch: `feat/job-` (timestamp-suffixed if branch already exists) + - Base: `origin/main` +2. Tool response includes `worktree_path` and `branch_name`. +3. **Work exclusively in `worktree_path`** — all file edits and commits go there. +4. On `update_job_status(done|failed)`, `removeWorktreeForJob` runs automatically: + - `keepBranch=true` if `done` and a `branch` was reported (agent pushed) + - `keepBranch=false` otherwise (branch deleted with worktree) + +### Required configuration + +Set env var per product: + +``` +SCRUM4ME_REPO_ROOT_=/absolute/path/to/local/clone +``` + +Or add to `~/.scrum4me-agent-config.json`: + +```json +{ + "repoRoots": { + "": "/absolute/path/to/local/clone" + } +} +``` + +If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error. + +## Manual worktree cleanup + +Run `cleanup_my_worktrees` (no arguments) to scan `~/.scrum4me-agent-worktrees/` and remove worktrees for jobs that are in a terminal state (DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are left untouched. Returns `{ removed, kept, skipped }`. + +## Key source files + +| File | Purpose | +|---|---| +| `src/git/worktree.ts` | `createWorktreeForJob` + `removeWorktreeForJob` | +| `src/tools/wait-for-job.ts` | `resolveRepoRoot`, `rollbackClaim`, `attachWorktreeToJob` | +| `src/tools/update-job-status.ts` | `cleanupWorktreeForTerminalStatus` | +| `src/tools/cleanup-my-worktrees.ts` | `cleanup_my_worktrees` tool — scans + removes stale worktrees | + +## Testing + +```bash +npm test # vitest run +npm run typecheck # tsc --noEmit +``` + +All worktree helpers have unit tests under `__tests__/git/worktree.test.ts`, `__tests__/wait-for-job-worktree.test.ts`, and `__tests__/update-job-status-worktree.test.ts`. diff --git a/README.md b/README.md index a6e1afb..34d47fb 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ activity and create todos via native tool calls instead of curl. | `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a | | `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a | | `cancel_question` | Cancel an own open question (asker-only) | no | -| `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot` | no | -| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI | no | +| `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot`, `worktree_path`, and `branch_name` | no | +| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI; cleans up worktree on terminal transitions | no | | `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) | Demo accounts may read but writes return `PERMISSION_DENIED`. @@ -116,6 +116,46 @@ Add to `~/.claude/mcp_servers.json`: Restart Claude Code. The 9 tools and 1 prompt show up under the `scrum4me` namespace. +## Agent worktree-flow + +When a job is claimed via `wait_for_job`, the MCP server automatically creates an isolated git worktree for the job under `~/.scrum4me-agent-worktrees//` with a dedicated branch `feat/job-`. The tool response includes: + +- `worktree_path` — absolute path to the worktree directory +- `branch_name` — the branch checked out in that worktree + +**The agent must work exclusively inside `worktree_path`**. All file edits and commits belong there; the user's main checkout stays clean. + +When `update_job_status` is called with `done` or `failed`, the worktree is automatically removed. If the agent reported a `branch` (indicating a push), the local branch is preserved on `done`; otherwise it is deleted together with the worktree directory. + +### Required env vars + +| Variable | Purpose | +|---|---| +| `SCRUM4ME_AGENT_WORKTREE_DIR` | Override the default worktree parent directory (default: `~/.scrum4me-agent-worktrees`) | +| `SCRUM4ME_REPO_ROOT_` | Absolute path to the local git clone for that product, e.g. `SCRUM4ME_REPO_ROOT_cmohrysyj0000rd17clnjy4tc=/home/user/projects/scrum4me` | + +Alternatively, configure repo roots in `~/.scrum4me-agent-config.json`: + +```json +{ + "repoRoots": { + "": "/home/user/projects/scrum4me" + } +} +``` + +If no repo root is configured for the product, `wait_for_job` rolls back the claim to `QUEUED` and returns an error. + +### Smoke-test checklist + +After starting the server on the feature branch: + +1. Enqueue a job in Scrum4Me (Solo Paneel → Start agent). +2. Call `wait_for_job` — response must contain `worktree_path` and `branch_name`. +3. In the **main checkout**: `git worktree list` → the agent worktree appears. +4. In the **main checkout**: `git status` → clean (no agent changes). +5. Call `update_job_status(done)` → worktree directory disappears. + ## Schema sync The Prisma schema is the source of truth in the upstream Scrum4Me diff --git a/__tests__/cleanup-my-worktrees.test.ts b/__tests__/cleanup-my-worktrees.test.ts new file mode 100644 index 0000000..6460157 --- /dev/null +++ b/__tests__/cleanup-my-worktrees.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as path from 'node:path' +import * as os from 'node:os' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + claudeJob: { findMany: vi.fn() }, + }, +})) + +vi.mock('../src/git/worktree.js', () => ({ + removeWorktreeForJob: vi.fn(), +})) + +vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, resolveRepoRoot: vi.fn() } +}) + +vi.mock('node:fs/promises', () => ({ + readdir: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { removeWorktreeForJob } from '../src/git/worktree.js' +import { resolveRepoRoot } from '../src/tools/wait-for-job.js' +import * as fsPromises from 'node:fs/promises' +import { cleanupWorktrees, listWorktreeJobIds, getWorktreeParent } from '../src/tools/cleanup-my-worktrees.js' + +const mockPrisma = prisma as unknown as { + claudeJob: { findMany: ReturnType } +} +const mockRemove = removeWorktreeForJob as ReturnType +const mockResolve = resolveRepoRoot as ReturnType +const mockReaddir = fsPromises.readdir as ReturnType + +const REPO_ROOT = '/repos/my-project' +const USER_ID = 'user-1' +const PRODUCT_ID = 'product-1' +const WORKTREE_PARENT = '/home/user/.scrum4me-agent-worktrees' + +function makeDirent(name: string, isDir = true) { + return { name, isDirectory: () => isDir } as unknown as import('node:fs').Dirent +} + +beforeEach(() => { + vi.clearAllMocks() + mockResolve.mockResolvedValue(REPO_ROOT) + mockRemove.mockResolvedValue({ removed: true }) +}) + +describe('getWorktreeParent', () => { + it('uses SCRUM4ME_AGENT_WORKTREE_DIR env var when set', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/custom/dir' + expect(await getWorktreeParent()).toBe('/custom/dir') + delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR + }) + + it('defaults to ~/.scrum4me-agent-worktrees', async () => { + delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR + expect(await getWorktreeParent()).toBe(path.join(os.homedir(), '.scrum4me-agent-worktrees')) + }) +}) + +describe('listWorktreeJobIds', () => { + it('returns directory names from the worktree parent', async () => { + mockReaddir.mockResolvedValue([makeDirent('job-aaa'), makeDirent('job-bbb'), makeDirent('file.txt', false)]) + const ids = await listWorktreeJobIds(WORKTREE_PARENT) + expect(ids).toEqual(['job-aaa', 'job-bbb']) + }) + + it('returns empty array when parent does not exist', async () => { + mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([]) + }) +}) + +describe('cleanupWorktrees', () => { + it('removes worktrees for DONE/FAILED/CANCELLED jobs', async () => { + mockReaddir.mockResolvedValue([ + makeDirent('job-done'), + makeDirent('job-failed'), + makeDirent('job-cancelled'), + ]) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' }, + { id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null }, + { id: 'job-cancelled', status: 'CANCELLED', product_id: PRODUCT_ID, branch: null }, + ]) + + const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(result.removed).toEqual(expect.arrayContaining(['job-done', 'job-failed', 'job-cancelled'])) + expect(result.kept).toEqual([]) + expect(result.skipped).toEqual([]) + expect(mockRemove).toHaveBeenCalledTimes(3) + }) + + it('keeps worktrees for QUEUED/CLAIMED/RUNNING jobs', async () => { + mockReaddir.mockResolvedValue([ + makeDirent('job-queued'), + makeDirent('job-claimed'), + makeDirent('job-running'), + ]) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { id: 'job-queued', status: 'QUEUED', product_id: PRODUCT_ID, branch: null }, + { id: 'job-claimed', status: 'CLAIMED', product_id: PRODUCT_ID, branch: null }, + { id: 'job-running', status: 'RUNNING', product_id: PRODUCT_ID, branch: null }, + ]) + + const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(result.kept).toEqual(expect.arrayContaining(['job-queued', 'job-claimed', 'job-running'])) + expect(result.removed).toEqual([]) + expect(mockRemove).not.toHaveBeenCalled() + }) + + it('calls removeWorktreeForJob with keepBranch=true for DONE with branch', async () => { + mockReaddir.mockResolvedValue([makeDirent('job-done')]) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' }, + ]) + + await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-done', keepBranch: true }) + }) + + it('calls removeWorktreeForJob with keepBranch=false for FAILED jobs', async () => { + mockReaddir.mockResolvedValue([makeDirent('job-failed')]) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null }, + ]) + + await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-failed', keepBranch: false }) + }) + + it('skips orphan worktrees (no DB record)', async () => { + mockReaddir.mockResolvedValue([makeDirent('job-orphan')]) + mockPrisma.claudeJob.findMany.mockResolvedValue([]) + + const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(result.skipped).toContain('job-orphan') + expect(mockRemove).not.toHaveBeenCalled() + }) + + it('skips worktrees when no repoRoot is configured', async () => { + mockReaddir.mockResolvedValue([makeDirent('job-norepo')]) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { id: 'job-norepo', status: 'FAILED', product_id: 'unknown-product', branch: null }, + ]) + mockResolve.mockResolvedValue(null) + + const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(result.skipped).toContain('job-norepo') + expect(mockRemove).not.toHaveBeenCalled() + }) + + it('returns empty result when worktree parent is empty', async () => { + mockReaddir.mockResolvedValue([]) + + const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID) + + expect(result).toEqual({ removed: [], kept: [], skipped: [] }) + expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/git/pr.test.ts b/__tests__/git/pr.test.ts new file mode 100644 index 0000000..6d8cc72 --- /dev/null +++ b/__tests__/git/pr.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: mockExecFile })) +vi.mock('node:util', () => ({ + promisify: + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + new Promise((resolve, reject) => + fn(...args, (err: Error | null, result: unknown) => (err ? reject(err) : resolve(result))), + ), +})) + +import { createPullRequest } from '../../src/git/pr.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createPullRequest', () => { + it('returns PR URL when gh succeeds', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string; stderr: string }) => void) => + cb(null, { stdout: 'Creating pull request...\nhttps://github.com/org/repo/pull/42\n', stderr: '' }), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'SCRUM-1: Add feature', + body: 'Summary\n\n---\n\n*Auto-generated*', + }) + + expect(result).toEqual({ url: 'https://github.com/org/repo/pull/42' }) + }) + + it('returns error when gh is not installed (ENOENT)', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => + cb(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'My PR', + body: 'Body', + }) + + expect(result).toMatchObject({ error: expect.stringContaining('gh CLI not found') }) + }) + + it('returns error on generic gh failure', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => + cb(new Error('authentication required')), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'My PR', + body: 'Body', + }) + + expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') }) + }) +}) diff --git a/__tests__/git/push.test.ts b/__tests__/git/push.test.ts new file mode 100644 index 0000000..5e216b1 --- /dev/null +++ b/__tests__/git/push.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})) + +import { execFile } from 'node:child_process' +import { pushBranchForJob } from '../../src/git/push.js' + +// promisify(execFile) will call execFile(cmd, args, opts, cb) internally +type ExecCallback = (err: Error | null, result?: { stdout: string; stderr: string }) => void +const mockExec = execFile as unknown as ReturnType + +const SHA_HEAD = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const SHA_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('pushBranchForJob', () => { + it('returns pushed=true with remoteRef on successful push', async () => { + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + // git push -u origin + return cb(null, { stdout: '', stderr: '' }) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toEqual({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' }) + }) + + it('returns no-changes when HEAD equals origin/main', async () => { + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD') || args.includes('origin/main')) { + return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + } + return cb(null, { stdout: '', stderr: '' }) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toEqual({ pushed: false, reason: 'no-changes', stderr: '' }) + }) + + it('returns no-credentials when push fails with Authentication failed', async () => { + const authError = Object.assign(new Error('git push failed'), { + stderr: 'fatal: Authentication failed for https://github.com/...', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(authError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'no-credentials' }) + expect((result as { stderr: string }).stderr).toContain('Authentication failed') + }) + + it('returns conflict when push is rejected (non-fast-forward)', async () => { + const conflictError = Object.assign(new Error('git push failed'), { + stderr: '! [rejected] feat/job-abc -> feat/job-abc (non-fast-forward)', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(conflictError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'conflict' }) + }) + + it('returns unknown for unrecognised push errors', async () => { + const unknownError = Object.assign(new Error('git push failed'), { + stderr: 'error: some unexpected thing happened', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(unknownError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'unknown' }) + }) +}) diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts new file mode 100644 index 0000000..d92ee00 --- /dev/null +++ b/__tests__/git/worktree.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import * as os from 'node:os' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { createWorktreeForJob, removeWorktreeForJob } from '../../src/git/worktree.js' + +const exec = promisify(execFile) + +async function git(args: string[], cwd: string) { + return exec('git', args, { cwd }) +} + +async function setupRepo(): Promise<{ repoDir: string; originDir: string }> { + const base = os.tmpdir() + const originDir = await fs.mkdtemp(path.join(base, 'scrum4me-origin-')) + const repoDir = await fs.mkdtemp(path.join(base, 'scrum4me-repo-')) + + await git(['init', '--bare'], originDir) + await git(['init'], repoDir) + await git(['config', 'user.email', 'test@test.com'], repoDir) + await git(['config', 'user.name', 'Test'], repoDir) + await git(['remote', 'add', 'origin', originDir], repoDir) + await fs.writeFile(path.join(repoDir, 'README.md'), '# test') + await git(['add', '.'], repoDir) + await git(['commit', '-m', 'init'], repoDir) + await git(['push', 'origin', 'HEAD:main'], repoDir) + + return { repoDir, originDir } +} + +describe('createWorktreeForJob', () => { + const tmpDirs: string[] = [] + const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR + + afterEach(async () => { + if (originalWorktreeDir === undefined) { + delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR + } else { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir + } + for (const dir of tmpDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + async function makeWorktreeParent(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-')) + tmpDirs.push(dir) + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir + return dir + } + + it('creates a worktree directory with the correct branch as HEAD', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-001', + branchName: 'feat/job-001', + baseRef: 'origin/main', + }) + + const stat = await fs.stat(result.worktreePath) + expect(stat.isDirectory()).toBe(true) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe('feat/job-001') + + expect(result.branchName).toBe('feat/job-001') + expect(result.worktreePath).toBe(path.join(wtParent, 'job-001')) + }) + + it('suffixes branch name with timestamp when branch already exists', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + await git(['branch', 'feat/job-002'], repoDir) + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-002', + branchName: 'feat/job-002', + baseRef: 'origin/main', + }) + + expect(result.branchName).toMatch(/^feat\/job-002-\d+$/) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe(result.branchName) + }) + + it('rejects when worktree path already exists', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const existingPath = path.join(wtParent, 'job-003') + await fs.mkdir(existingPath) + + await expect( + createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-003', + branchName: 'feat/job-003', + baseRef: 'origin/main', + }), + ).rejects.toThrow('Worktree path already exists') + }) +}) + +describe('removeWorktreeForJob', () => { + const tmpDirs: string[] = [] + const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR + + afterEach(async () => { + if (originalWorktreeDir === undefined) { + delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR + } else { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir + } + for (const dir of tmpDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + async function makeWorktreeParent(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-')) + tmpDirs.push(dir) + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir + return dir + } + + it('removes worktree directory and deletes branch by default', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const { worktreePath, branchName } = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-01', + branchName: 'feat/job-rm-01', + baseRef: 'origin/main', + }) + + const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-01' }) + + expect(result.removed).toBe(true) + await expect(fs.access(worktreePath)).rejects.toThrow() + await expect(fs.access(path.join(wtParent, 'job-rm-01'))).rejects.toThrow() + + // Branch should be deleted + await expect( + exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { + cwd: repoDir, + }), + ).rejects.toThrow() + }) + + it('removes worktree directory but keeps branch when keepBranch=true', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const { worktreePath, branchName } = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-02', + branchName: 'feat/job-rm-02', + baseRef: 'origin/main', + }) + + const result = await removeWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-02', + keepBranch: true, + }) + + expect(result.removed).toBe(true) + await expect(fs.access(worktreePath)).rejects.toThrow() + await expect(fs.access(path.join(wtParent, 'job-rm-02'))).rejects.toThrow() + + // Branch should still exist + const { stdout } = await exec( + 'git', + ['show-ref', '--verify', `refs/heads/${branchName}`], + { cwd: repoDir }, + ) + expect(stdout).toContain(branchName) + }) + + it('returns { removed: false } when worktree does not exist', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-nonexistent' }) + + expect(result.removed).toBe(false) + }) +}) diff --git a/__tests__/update-job-status-auto-pr.test.ts b/__tests__/update-job-status-auto-pr.test.ts new file mode 100644 index 0000000..55db4cf --- /dev/null +++ b/__tests__/update-job-status-auto-pr.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + product: { findUnique: vi.fn() }, + task: { findUnique: vi.fn() }, + }, +})) + +vi.mock('../src/git/pr.js', () => ({ + createPullRequest: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { createPullRequest } from '../src/git/pr.js' +import { maybeCreateAutoPr } from '../src/tools/update-job-status.js' + +const mockPrisma = prisma as unknown as { + product: { findUnique: ReturnType } + task: { findUnique: ReturnType } +} +const mockCreatePr = createPullRequest as ReturnType + +const BASE_OPTS = { + jobId: 'job-abc', + productId: 'prod-1', + taskId: 'task-1', + worktreePath: '/wt/job-abc', + branchName: 'feat/job-abc', + summary: 'Implemented the feature', +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true }) + mockPrisma.task.findUnique.mockResolvedValue({ + title: 'Add feature', + story: { code: 'SCRUM-42' }, + }) + mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' }) +}) + +describe('maybeCreateAutoPr', () => { + it('returns PR URL when auto_pr=true and gh succeeds', async () => { + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBe('https://github.com/org/repo/pull/99') + expect(mockCreatePr).toHaveBeenCalledWith({ + worktreePath: BASE_OPTS.worktreePath, + branchName: BASE_OPTS.branchName, + title: 'SCRUM-42: Add feature', + body: expect.stringContaining(BASE_OPTS.summary), + }) + }) + + it('returns null when auto_pr=false', async () => { + mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false }) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + expect(mockCreatePr).not.toHaveBeenCalled() + }) + + it('uses task title without code prefix when story has no code', async () => { + mockPrisma.task.findUnique.mockResolvedValue({ + title: 'Add feature', + story: { code: null }, + }) + await maybeCreateAutoPr(BASE_OPTS) + expect(mockCreatePr).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Add feature' }), + ) + }) + + it('returns null and does not throw when gh fails', async () => { + mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' }) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + }) + + it('returns null when product not found', async () => { + mockPrisma.product.findUnique.mockResolvedValue(null) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + expect(mockCreatePr).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/update-job-status-push.test.ts b/__tests__/update-job-status-push.test.ts new file mode 100644 index 0000000..1232670 --- /dev/null +++ b/__tests__/update-job-status-push.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import * as path from 'node:path' + +vi.mock('../src/git/push.js', () => ({ + pushBranchForJob: vi.fn(), +})) + +import { pushBranchForJob } from '../src/git/push.js' +import { prepareDoneUpdate } from '../src/tools/update-job-status.js' + +const mockPush = pushBranchForJob as ReturnType + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('prepareDoneUpdate', () => { + const originalEnv = { ...process.env } + + afterEach(() => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv.SCRUM4ME_AGENT_WORKTREE_DIR + }) + + it('returns DONE with pushedAt and branchOverride when push succeeds', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' }) + + const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc') + + expect(plan.dbStatus).toBe('DONE') + expect(plan.pushedAt).toBeInstanceOf(Date) + expect(plan.branchOverride).toBe('feat/job-abc') + expect(plan.errorOverride).toBeUndefined() + expect(plan.skipWorktreeCleanup).toBe(false) + + expect(mockPush).toHaveBeenCalledWith({ + worktreePath: path.join('/wt', 'job-abc'), + branchName: 'feat/job-abc', + }) + }) + + it('derives branchName from jobId when branch is undefined', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' }) + + await prepareDoneUpdate('job-abc12345', undefined) + + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ branchName: 'feat/job-abc12345' }), + ) + }) + + it('returns DONE without pushedAt when no-changes', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ pushed: false, reason: 'no-changes', stderr: '' }) + + const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc') + + expect(plan.dbStatus).toBe('DONE') + expect(plan.pushedAt).toBeUndefined() + expect(plan.branchOverride).toBeUndefined() + expect(plan.errorOverride).toBeUndefined() + expect(plan.skipWorktreeCleanup).toBe(false) + }) + + it('returns FAILED with error and skipWorktreeCleanup when no-credentials', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ + pushed: false, + reason: 'no-credentials', + stderr: 'fatal: Authentication failed', + }) + + const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc') + + expect(plan.dbStatus).toBe('FAILED') + expect(plan.errorOverride).toContain('push failed (no-credentials)') + expect(plan.errorOverride).toContain('Authentication failed') + expect(plan.skipWorktreeCleanup).toBe(true) + }) + + it('returns FAILED with error and skipWorktreeCleanup when conflict', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ + pushed: false, + reason: 'conflict', + stderr: '! [rejected] non-fast-forward', + }) + + const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc') + + expect(plan.dbStatus).toBe('FAILED') + expect(plan.errorOverride).toContain('push failed (conflict)') + expect(plan.skipWorktreeCleanup).toBe(true) + }) + + it('returns FAILED with error and skipWorktreeCleanup when unknown push error', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockPush.mockResolvedValue({ + pushed: false, + reason: 'unknown', + stderr: 'something went wrong', + }) + + const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc') + + expect(plan.dbStatus).toBe('FAILED') + expect(plan.skipWorktreeCleanup).toBe(true) + }) +}) diff --git a/__tests__/update-job-status-worktree.test.ts b/__tests__/update-job-status-worktree.test.ts new file mode 100644 index 0000000..5b084b4 --- /dev/null +++ b/__tests__/update-job-status-worktree.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/git/worktree.js', () => ({ + removeWorktreeForJob: vi.fn(), +})) + +vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveRepoRoot: vi.fn(), + } +}) + +import { removeWorktreeForJob } from '../src/git/worktree.js' +import { resolveRepoRoot } from '../src/tools/wait-for-job.js' +import { cleanupWorktreeForTerminalStatus } from '../src/tools/update-job-status.js' + +const mockRemove = removeWorktreeForJob as ReturnType +const mockResolve = resolveRepoRoot as ReturnType + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('cleanupWorktreeForTerminalStatus', () => { + it('calls removeWorktreeForJob with keepBranch=true when done and branch set', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockResolvedValue({ removed: true }) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc') + + expect(mockRemove).toHaveBeenCalledWith({ + repoRoot: '/repos/my-project', + jobId: 'job-abc', + keepBranch: true, + }) + }) + + it('calls removeWorktreeForJob with keepBranch=false when done but no branch', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockResolvedValue({ removed: true }) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', undefined) + + expect(mockRemove).toHaveBeenCalledWith({ + repoRoot: '/repos/my-project', + jobId: 'job-abc', + keepBranch: false, + }) + }) + + it('calls removeWorktreeForJob with keepBranch=false when failed', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockResolvedValue({ removed: true }) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'failed', 'feat/job-abc') + + expect(mockRemove).toHaveBeenCalledWith({ + repoRoot: '/repos/my-project', + jobId: 'job-abc', + keepBranch: false, + }) + }) + + it('skips cleanup and does not throw when no repoRoot configured', async () => { + mockResolve.mockResolvedValue(null) + + await expect( + cleanupWorktreeForTerminalStatus('prod-no-root', 'job-abc', 'done', undefined), + ).resolves.toBeUndefined() + + expect(mockRemove).not.toHaveBeenCalled() + }) + + it('does not throw when removeWorktreeForJob fails (best-effort)', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockRejectedValue(new Error('git error')) + + await expect( + cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc'), + ).resolves.toBeUndefined() + }) +}) diff --git a/__tests__/verify-task-against-plan.test.ts b/__tests__/verify-task-against-plan.test.ts new file mode 100644 index 0000000..f7aa838 --- /dev/null +++ b/__tests__/verify-task-against-plan.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + task: { findUnique: vi.fn() }, + claudeJob: { update: vi.fn() }, + }, +})) + +vi.mock('../src/verify/classify.js', () => ({ + classifyDiffAgainstPlan: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { classifyDiffAgainstPlan } from '../src/verify/classify.js' +import { getDiffInWorktree, saveVerifyResult } from '../src/tools/verify-task-against-plan.js' + +const mockPrisma = prisma as unknown as { + task: { findUnique: ReturnType } + claudeJob: { update: ReturnType } +} +const mockClassify = classifyDiffAgainstPlan as ReturnType + +// Mock node:child_process so getDiffInWorktree doesn't need a real git repo +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})) + +import { execFile } from 'node:child_process' +const mockExecFile = execFile as unknown as ReturnType + +// Promisify internally calls execFile in callback form: (cmd, args, opts, cb) +function stubExecFile(stdout: string) { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: null, result: { stdout: string; stderr: string }) => void) => { + cb(null, { stdout, stderr: '' }) + }, + ) +} + +function stubExecFileError(message: string) { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => { + cb(new Error(message)) + }, + ) +} + +const ALIGNED_DIFF = `diff --git a/src/verify/classify.ts b/src/verify/classify.ts +--- a/src/verify/classify.ts ++++ b/src/verify/classify.ts ++export function classifyDiffAgainstPlan(opts) {}` + +describe('getDiffInWorktree', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns stdout from git diff', async () => { + stubExecFile(ALIGNED_DIFF) + const result = await getDiffInWorktree('/worktrees/job-abc') + expect(result).toBe(ALIGNED_DIFF) + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['diff', 'origin/main...HEAD'], + expect.objectContaining({ cwd: '/worktrees/job-abc' }), + expect.any(Function), + ) + }) + + it('throws when git diff fails', async () => { + stubExecFileError('not a git repo') + await expect(getDiffInWorktree('/bad/path')).rejects.toThrow('not a git repo') + }) +}) + +describe('saveVerifyResult', () => { + beforeEach(() => vi.clearAllMocks()) + + it('updates claudeJob with the given verify_result', async () => { + mockPrisma.claudeJob.update.mockResolvedValue({}) + await saveVerifyResult('job-123', 'ALIGNED') + expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({ + where: { id: 'job-123' }, + data: { verify_result: 'ALIGNED' }, + }) + }) +}) + +describe('verify_task_against_plan — integration of helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + stubExecFile(ALIGNED_DIFF) + mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'All paths covered.' }) + mockPrisma.claudeJob.update.mockResolvedValue({}) + }) + + it('happy path: runs diff, classifies, saves verify_result', async () => { + // Simulate: getDiffInWorktree + classifyDiffAgainstPlan + saveVerifyResult + const diff = await getDiffInWorktree('/wt/job-abc') + mockClassify({ diff, plan: 'Modify `src/verify/classify.ts`.' }) + expect(mockClassify).toHaveBeenCalledWith(expect.objectContaining({ diff })) + + await saveVerifyResult('job-123', 'ALIGNED') + expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({ + where: { id: 'job-123' }, + data: { verify_result: 'ALIGNED' }, + }) + }) + + it('EMPTY path: saves EMPTY to DB', async () => { + stubExecFile('') // no diff output + mockClassify.mockReturnValue({ result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' }) + + const diff = await getDiffInWorktree('/wt/job-xyz') + const { result } = mockClassify({ diff, plan: null }) + expect(result).toBe('EMPTY') + + await saveVerifyResult('job-xyz', 'EMPTY') + expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({ + where: { id: 'job-xyz' }, + data: { verify_result: 'EMPTY' }, + }) + }) +}) diff --git a/__tests__/verify/classify.test.ts b/__tests__/verify/classify.test.ts new file mode 100644 index 0000000..690aa04 --- /dev/null +++ b/__tests__/verify/classify.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { classifyDiffAgainstPlan } from '../../src/verify/classify.js' + +// Helpers to build minimal unified diff snippets +function makeDiff(files: string[], linesPerFile = 5): string { + return files + .map( + (f) => + `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n` + + Array.from({ length: linesPerFile }, (_, i) => `+added line ${i}`).join('\n'), + ) + .join('\n') +} + +function largeFileDiff(file: string, addedLines = 60): string { + return ( + `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n` + + Array.from({ length: addedLines }, (_, i) => `+line ${i}`).join('\n') + ) +} + +describe('classifyDiffAgainstPlan — empty diff', () => { + it('returns EMPTY for blank diff string', () => { + const r = classifyDiffAgainstPlan({ diff: '', plan: 'some plan' }) + expect(r.result).toBe('EMPTY') + expect(r.reasoning).toMatch(/geen bestandswijzigingen/i) + }) + + it('returns EMPTY for diff with no +++ b/ lines', () => { + const r = classifyDiffAgainstPlan({ diff: 'Binary files differ\n', plan: 'plan' }) + expect(r.result).toBe('EMPTY') + }) +}) + +describe('classifyDiffAgainstPlan — no plan baseline', () => { + it('returns ALIGNED for null plan with small diff', () => { + const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: null }) + expect(r.result).toBe('ALIGNED') + }) + + it('returns ALIGNED for empty string plan with small diff', () => { + const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: ' ' }) + expect(r.result).toBe('ALIGNED') + }) + + it('returns DIVERGENT for null plan with large diff (>50 changed lines)', () => { + const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/big.ts', 60), plan: null }) + expect(r.result).toBe('DIVERGENT') + }) +}) + +describe('classifyDiffAgainstPlan — plan has no extractable paths', () => { + const plan = 'Add a new feature. Update the logic. Write tests.' + + it('returns ALIGNED for small diff when plan has no paths', () => { + const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/feature.ts']), plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('returns DIVERGENT for large diff when plan has no paths', () => { + const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/feature.ts', 60), plan }) + expect(r.result).toBe('DIVERGENT') + }) +}) + +describe('classifyDiffAgainstPlan — PARTIAL coverage', () => { + const plan = 'Edit `src/api.ts` and `src/ui.ts`.' + + it('returns PARTIAL when only one of two plan paths is in diff', () => { + const diff = makeDiff(['src/api.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('PARTIAL') + expect(r.reasoning).toMatch(/1\/2/) + expect(r.reasoning).toMatch(/ontbrekend/i) + }) + + it('returns PARTIAL when none of the plan paths are in diff', () => { + const diff = makeDiff(['src/unrelated.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('PARTIAL') + expect(r.reasoning).toMatch(/0\/2/) + }) +}) + +describe('classifyDiffAgainstPlan — ALIGNED', () => { + it('returns ALIGNED when all plan paths are in diff with ratio < 3', () => { + const plan = 'Modify `src/a.ts` and `src/b.ts`.' + const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts']) // ratio 3/2 = 1.5 < 3 + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + expect(r.reasoning).toMatch(/alle 2/i) + }) + + it('returns ALIGNED for exact 1-to-1 match', () => { + const plan = 'Update `src/verify/classify.ts`.' + const diff = makeDiff(['src/verify/classify.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('suffix-matches short plan path against full diff path', () => { + const plan = 'Edit `classify.ts` in the verify module.' + const diff = makeDiff(['src/verify/classify.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) +}) + +describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => { + it('returns DIVERGENT when diff has 3x+ more paths than plan', () => { + const plan = 'Update `src/a.ts`.' + // 1 plan path, 4 diff paths → ratio 4.0 >= 3 + const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('DIVERGENT') + expect(r.reasoning).toMatch(/4\.0x/) + }) + + it('shows extra file paths in reasoning', () => { + const plan = 'Modify `src/core.ts`.' + const diff = makeDiff(['src/core.ts', 'src/extra1.ts', 'src/extra2.ts', 'src/extra3.ts', 'src/extra4.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('DIVERGENT') + expect(r.reasoning).toMatch(/extra/i) + }) +}) diff --git a/__tests__/wait-for-job-snapshot.test.ts b/__tests__/wait-for-job-snapshot.test.ts index e4eb059..bb2e871 100644 --- a/__tests__/wait-for-job-snapshot.test.ts +++ b/__tests__/wait-for-job-snapshot.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../src/prisma.js', () => ({ prisma: { - $executeRaw: vi.fn(), + $queryRaw: vi.fn(), $transaction: vi.fn(), }, })) @@ -11,27 +11,41 @@ import { prisma } from '../src/prisma.js' import { resetStaleClaimedJobs, tryClaimJob } from '../src/tools/wait-for-job.js' const mockPrisma = prisma as unknown as { - $executeRaw: ReturnType + $queryRaw: ReturnType $transaction: ReturnType } beforeEach(() => { vi.clearAllMocks() + // Default: no stale jobs returned from either query + mockPrisma.$queryRaw.mockResolvedValue([]) }) describe('resetStaleClaimedJobs', () => { - it('resets plan_snapshot to NULL when resetting stale claimed jobs', async () => { - mockPrisma.$executeRaw.mockResolvedValue(0) + it('runs two $queryRaw calls: one for FAILED, one for QUEUED re-enqueue', async () => { await resetStaleClaimedJobs('user-1') + // Two queries: failed jobs + requeued jobs + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2) + }) - expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce() - // Verify the template literal includes plan_snapshot = NULL - const call = mockPrisma.$executeRaw.mock.calls[0] - const sqlParts: string[] = call[0] - const fullSql = sqlParts.join('') - expect(fullSql).toContain('plan_snapshot = NULL') - expect(fullSql).toContain("status = 'QUEUED'") - expect(fullSql).toContain('claimed_at < NOW()') + it('FAILED query includes plan_snapshot = NULL reset and retry_count >= 2', async () => { + await resetStaleClaimedJobs('user-1') + const calls = mockPrisma.$queryRaw.mock.calls + // First call: FAILED transition + const failedSql = (calls[0][0] as string[]).join('') + expect(failedSql).toContain("status = 'FAILED'") + expect(failedSql).toContain('retry_count >= 2') + }) + + it('QUEUED re-enqueue query includes plan_snapshot = NULL and retry_count increment', async () => { + await resetStaleClaimedJobs('user-1') + const calls = mockPrisma.$queryRaw.mock.calls + // Second call: re-enqueue transition + const requeueSql = (calls[1][0] as string[]).join('') + expect(requeueSql).toContain("status = 'QUEUED'") + expect(requeueSql).toContain('plan_snapshot = NULL') + expect(requeueSql).toContain('retry_count = retry_count + 1') + expect(requeueSql).toContain('retry_count < 2') }) }) 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/prisma/schema.prisma b/prisma/schema.prisma index 461ebdc..19ddd81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,13 @@ enum SprintStatus { COMPLETED } +enum VerifyResult { + ALIGNED + PARTIAL + EMPTY + DIVERGENT +} + model User { id String @id @default(cuid()) username String @unique @@ -120,6 +127,7 @@ model Product { description String? repo_url String? definition_of_done String + auto_pr Boolean @default(false) archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -232,6 +240,7 @@ model Task { priority Int sort_order Float status TaskStatus @default(TO_DO) + verify_only Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] @@ -256,10 +265,14 @@ model ClaudeJob { claimed_at DateTime? started_at DateTime? finished_at DateTime? + pushed_at DateTime? plan_snapshot String? branch String? + pr_url String? summary String? error String? + verify_result VerifyResult? + retry_count Int @default(0) created_at DateTime @default(now()) updated_at DateTime @updatedAt diff --git a/src/git/pr.ts b/src/git/pr.ts new file mode 100644 index 0000000..2f98b92 --- /dev/null +++ b/src/git/pr.ts @@ -0,0 +1,38 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +export async function createPullRequest(opts: { + worktreePath: string + branchName: string + title: string + body: string +}): Promise<{ url: string } | { error: string }> { + const { worktreePath, branchName, title, body } = opts + + try { + const { stdout } = await exec( + 'gh', + ['pr', 'create', '--title', title, '--body', body, '--head', branchName], + { cwd: worktreePath }, + ) + // gh prints the PR URL as the last non-empty line + const lines = stdout.trim().split('\n').filter(Boolean) + const url = lines[lines.length - 1]?.trim() ?? '' + if (!url.startsWith('http')) { + return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` } + } + return { url } + } catch (err: unknown) { + const msg = (err as { message?: string }).message ?? String(err) + const isNotFound = + msg.includes('command not found') || + msg.includes('is not recognized') || + msg.includes('ENOENT') + if (isNotFound) { + return { error: 'gh CLI not found — install GitHub CLI to enable auto-PR' } + } + return { error: `gh pr create failed: ${msg.slice(0, 300)}` } + } +} diff --git a/src/git/push.ts b/src/git/push.ts new file mode 100644 index 0000000..6003dc3 --- /dev/null +++ b/src/git/push.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +type PushSuccess = { pushed: true; remoteRef: string } +type PushFailure = { + pushed: false + reason: 'no-credentials' | 'conflict' | 'no-changes' | 'unknown' + stderr: string +} + +export type PushResult = PushSuccess | PushFailure + +export async function pushBranchForJob(opts: { + worktreePath: string + branchName: string +}): Promise { + const { worktreePath, branchName } = opts + + // Detect no new commits vs origin/main + let headSha: string + let baseSha: string + try { + const [headResult, baseResult] = await Promise.all([ + exec('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }), + exec('git', ['rev-parse', 'origin/main'], { cwd: worktreePath }), + ]) + headSha = headResult.stdout.trim() + baseSha = baseResult.stdout.trim() + } catch (err) { + return { pushed: false, reason: 'unknown', stderr: (err as Error).message } + } + + if (headSha === baseSha) { + return { pushed: false, reason: 'no-changes', stderr: '' } + } + + // Push + try { + await exec('git', ['push', '-u', 'origin', branchName], { cwd: worktreePath }) + return { pushed: true, remoteRef: `refs/heads/${branchName}` } + } catch (err) { + const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? '' + if (/Authentication failed|could not read Username/i.test(stderr)) { + return { pushed: false, reason: 'no-credentials', stderr } + } + if (/non-fast-forward|already exists on the remote|rejected/i.test(stderr)) { + return { pushed: false, reason: 'conflict', stderr } + } + return { pushed: false, reason: 'unknown', stderr } + } +} diff --git a/src/git/worktree.ts b/src/git/worktree.ts new file mode 100644 index 0000000..dd5e26d --- /dev/null +++ b/src/git/worktree.ts @@ -0,0 +1,97 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import * as path from 'node:path' +import * as os from 'node:os' +import * as fs from 'node:fs/promises' + +const exec = promisify(execFile) + +async function branchExists(repoRoot: string, name: string): Promise { + try { + await exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repoRoot }) + return true + } catch { + return false + } +} + +export async function createWorktreeForJob(opts: { + repoRoot: string + jobId: string + branchName: string + baseRef?: string +}): Promise<{ worktreePath: string; branchName: string }> { + const { repoRoot, jobId, baseRef = 'origin/main' } = opts + let { branchName } = opts + + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + + await fs.mkdir(parent, { recursive: true }) + + const worktreePath = path.join(parent, jobId) + + // Reject if worktree path already exists — caller must remove it first + try { + await fs.access(worktreePath) + throw new Error( + `Worktree path already exists: ${worktreePath}. Call removeWorktreeForJob first.`, + ) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + + await exec('git', ['fetch', 'origin', '--prune'], { cwd: repoRoot }) + + // Suffix with timestamp when branch already exists + if (await branchExists(repoRoot, branchName)) { + branchName = `${branchName}-${Date.now()}` + } + + await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { + cwd: repoRoot, + }) + + return { worktreePath, branchName } +} + +export async function removeWorktreeForJob(opts: { + repoRoot: string + jobId: string + keepBranch?: boolean +}): Promise<{ removed: boolean }> { + const { repoRoot, jobId, keepBranch = false } = opts + + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + + const worktreePath = path.join(parent, jobId) + + try { + await fs.access(worktreePath) + } catch { + return { removed: false } + } + + let branchName: string | undefined + if (!keepBranch) { + try { + const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: worktreePath, + }) + branchName = stdout.trim() + } catch { + // worktree HEAD unreadable — skip branch deletion + } + } + + await exec('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }) + + if (!keepBranch && branchName && (await branchExists(repoRoot, branchName))) { + await exec('git', ['branch', '-D', branchName], { cwd: repoRoot }) + } + + return { removed: true } +} diff --git a/src/index.ts b/src/index.ts index d7c2371..15479e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { registerCancelQuestionTool } from './tools/cancel-question.js' import { registerWaitForJobTool } from './tools/wait-for-job.js' import { registerUpdateJobStatusTool } from './tools/update-job-status.js' import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js' +import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' const VERSION = '0.1.0' @@ -53,6 +54,7 @@ async function main() { registerWaitForJobTool(server) registerUpdateJobStatusTool(server) registerVerifyTaskAgainstPlanTool(server) + registerCleanupMyWorktreesTool(server) registerImplementNextStoryPrompt(server) const transport = new StdioServerTransport() diff --git a/src/tools/cleanup-my-worktrees.ts b/src/tools/cleanup-my-worktrees.ts new file mode 100644 index 0000000..bfcc444 --- /dev/null +++ b/src/tools/cleanup-my-worktrees.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { toolJson, withToolErrors } from '../errors.js' +import { removeWorktreeForJob } from '../git/worktree.js' +import { resolveRepoRoot } from './wait-for-job.js' + +const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED']) +const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING']) + +const inputSchema = z.object({}) + +export async function getWorktreeParent(): Promise { + return ( + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + ) +} + +export async function listWorktreeJobIds(worktreeParent: string): Promise { + try { + const entries = await fs.readdir(worktreeParent, { withFileTypes: true }) + return entries.filter((e) => e.isDirectory()).map((e) => e.name) + } catch { + return [] + } +} + +export async function cleanupWorktrees( + worktreeParent: string, + userId: string, +): Promise<{ removed: string[]; kept: string[]; skipped: string[] }> { + const jobIds = await listWorktreeJobIds(worktreeParent) + const removed: string[] = [] + const kept: string[] = [] + const skipped: string[] = [] + + if (jobIds.length === 0) return { removed, kept, skipped } + + const jobs = await prisma.claudeJob.findMany({ + where: { id: { in: jobIds }, user_id: userId }, + select: { id: true, status: true, product_id: true, branch: true }, + }) + const jobMap = new Map(jobs.map((j) => [j.id, j])) + + for (const jobId of jobIds) { + const job = jobMap.get(jobId) + + // No DB record for this jobId — orphan worktree, skip safely + if (!job) { + skipped.push(jobId) + continue + } + + if (ACTIVE_STATUSES.has(job.status)) { + kept.push(jobId) + continue + } + + if (TERMINAL_STATUSES.has(job.status)) { + const repoRoot = await resolveRepoRoot(job.product_id) + if (!repoRoot) { + skipped.push(jobId) + continue + } + + // Keep branch for DONE jobs that already pushed (job.branch is set) + const keepBranch = job.status === 'DONE' && job.branch !== null + try { + await removeWorktreeForJob({ repoRoot, jobId, keepBranch }) + removed.push(jobId) + } catch { + skipped.push(jobId) + } + } + } + + return { removed, kept, skipped } +} + +export function registerCleanupMyWorktreesTool(server: McpServer) { + server.registerTool( + 'cleanup_my_worktrees', + { + title: 'Cleanup my worktrees', + description: + 'Remove stale git worktrees left by crashed or cancelled agent runs. ' + + 'Scans ~/.scrum4me-agent-worktrees/ (or SCRUM4ME_AGENT_WORKTREE_DIR) for job directories, ' + + 'looks up each job\'s status, and removes worktrees whose jobs are in a terminal state ' + + '(DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are kept. ' + + 'Returns { removed, kept, skipped } for inspection.', + inputSchema, + annotations: { readOnlyHint: false }, + }, + async () => + withToolErrors(async () => { + const auth = await requireWriteAccess() + const worktreeParent = await getWorktreeParent() + const result = await cleanupWorktrees(worktreeParent, auth.userId) + return toolJson(result) + }), + ) +} diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 21ec566..fb5fe17 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -5,9 +5,15 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { Client } from 'pg' +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 { removeWorktreeForJob } from '../git/worktree.js' +import { resolveRepoRoot } from './wait-for-job.js' +import { pushBranchForJob } from '../git/push.js' +import { createPullRequest } from '../git/pr.js' const inputSchema = z.object({ job_id: z.string().min(1), @@ -17,12 +23,114 @@ const inputSchema = z.object({ error: z.string().max(2_000).optional(), }) +export async function cleanupWorktreeForTerminalStatus( + productId: string, + jobId: string, + status: 'done' | 'failed', + branch: string | undefined, +): Promise { + const repoRoot = await resolveRepoRoot(productId) + if (!repoRoot) return + + // Keep branch when job is done and a branch was reported (agent pushed) + const keepBranch = status === 'done' && branch !== undefined + try { + await removeWorktreeForJob({ repoRoot, jobId, keepBranch }) + } catch (err) { + console.warn(`[update_job_status] Worktree cleanup failed for job ${jobId}:`, err) + } +} + +export type DoneUpdatePlan = { + dbStatus: 'DONE' | 'FAILED' + pushedAt: Date | undefined + branchOverride: string | undefined + errorOverride: string | undefined + skipWorktreeCleanup: boolean +} + +export async function prepareDoneUpdate( + jobId: string, + branch: string | undefined, +): Promise { + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees') + const worktreePath = path.join(worktreeDir, jobId) + const branchName = branch ?? `feat/job-${jobId.slice(-8)}` + + const pushResult = await pushBranchForJob({ worktreePath, branchName }) + + if (pushResult.pushed) { + return { + dbStatus: 'DONE', + pushedAt: new Date(), + branchOverride: branchName, + errorOverride: undefined, + skipWorktreeCleanup: false, + } + } + + if (pushResult.reason === 'no-changes') { + return { + dbStatus: 'DONE', + pushedAt: undefined, + branchOverride: undefined, + errorOverride: undefined, + skipWorktreeCleanup: false, + } + } + + // Push failed — job becomes FAILED, worktree stays for manual inspection + const snippet = pushResult.stderr.slice(0, 200) + return { + dbStatus: 'FAILED', + pushedAt: undefined, + branchOverride: undefined, + errorOverride: `push failed (${pushResult.reason}): ${snippet}`, + skipWorktreeCleanup: true, + } +} + const DB_STATUS_MAP = { running: 'RUNNING', done: 'DONE', failed: 'FAILED', } as const +export async function maybeCreateAutoPr(opts: { + jobId: string + productId: string + taskId: string + worktreePath: string + branchName: string + summary: string | undefined +}): Promise { + const { jobId, productId, taskId, worktreePath, branchName, summary } = opts + + const product = await prisma.product.findUnique({ + where: { id: productId }, + select: { auto_pr: true }, + }) + if (!product?.auto_pr) return null + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { title: true, story: { select: { code: true } } }, + }) + if (!task) return null + + const title = task.story.code ? `${task.story.code}: ${task.title}` : task.title + const body = summary + ? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent*` + : '*Auto-generated by Scrum4Me agent*' + + const result = await createPullRequest({ worktreePath, branchName, title, body }) + if ('url' in result) return result.url + + console.warn(`[update_job_status] auto-PR skipped for job ${jobId}:`, result.error) + return null +} + export function registerUpdateJobStatusTool(server: McpServer) { server.registerTool( 'update_job_status', @@ -60,22 +168,61 @@ export function registerUpdateJobStatusTool(server: McpServer) { return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`) } - const dbStatus = DB_STATUS_MAP[status] + // For DONE: push first, adjust DB status based on result + let actualStatus = status + let pushedAt: Date | undefined + let branchToWrite = branch + let errorToWrite = error + let skipWorktreeCleanup = false + + if (status === 'done') { + const plan = await prepareDoneUpdate(job_id, branch) + actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed' + pushedAt = plan.pushedAt + if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride + if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride + skipWorktreeCleanup = plan.skipWorktreeCleanup + } + + // Auto-PR: best-effort, only when push actually happened + let prUrl: string | null = null + if (actualStatus === 'done' && pushedAt && branchToWrite) { + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + prUrl = await maybeCreateAutoPr({ + jobId: job_id, + productId: job.product_id, + taskId: job.task_id, + worktreePath: path.join(worktreeDir, job_id), + branchName: branchToWrite, + summary, + }).catch((err) => { + console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err) + return null + }) + } + + const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP] const now = new Date() const updated = await prisma.claudeJob.update({ where: { id: job_id }, data: { status: dbStatus, - ...(status === 'running' ? { started_at: now } : {}), - ...(status === 'done' || status === 'failed' ? { finished_at: now } : {}), - ...(branch !== undefined ? { branch } : {}), + ...(actualStatus === 'running' ? { started_at: now } : {}), + ...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}), + ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}), + ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}), - ...(error !== undefined ? { error } : {}), + ...(errorToWrite !== undefined ? { error: errorToWrite } : {}), + ...(prUrl !== null ? { pr_url: prUrl } : {}), }, select: { id: true, status: true, branch: true, + pushed_at: true, + pr_url: true, summary: true, error: true, started_at: true, @@ -96,8 +243,10 @@ export function registerUpdateJobStatusTool(server: McpServer) { task_id: job.task_id, user_id: job.user_id, product_id: job.product_id, - status, + status: actualStatus, branch: updated.branch ?? undefined, + pushed_at: updated.pushed_at?.toISOString() ?? undefined, + pr_url: updated.pr_url ?? undefined, summary: updated.summary ?? undefined, error: updated.error ?? undefined, }), @@ -108,10 +257,17 @@ export function registerUpdateJobStatusTool(server: McpServer) { // non-fatal — status is already persisted } + // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) + if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) { + await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite) + } + return toolJson({ job_id: updated.id, - status, + status: actualStatus, branch: updated.branch, + pushed_at: updated.pushed_at?.toISOString() ?? null, + pr_url: updated.pr_url ?? null, summary: updated.summary, error: updated.error, started_at: updated.started_at?.toISOString() ?? null, diff --git a/src/tools/verify-task-against-plan.ts b/src/tools/verify-task-against-plan.ts index e04386e..40986ad 100644 --- a/src/tools/verify-task-against-plan.ts +++ b/src/tools/verify-task-against-plan.ts @@ -1,28 +1,47 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { prisma } from '../prisma.js' import { getAuth } from '../auth.js' import { userCanAccessTask } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' -import { buildVerifyResult, renderMarkdownReport } from '../lib/verify-plan.js' +import { classifyDiffAgainstPlan, type VerifyResultValue } from '../verify/classify.js' + +const exec = promisify(execFile) const inputSchema = z.object({ task_id: z.string().min(1), + worktree_path: z.string().min(1), }) +export async function getDiffInWorktree(worktreePath: string): Promise { + const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath }) + return stdout +} + +export async function saveVerifyResult(jobId: string, result: VerifyResultValue): Promise { + await prisma.claudeJob.update({ + where: { id: jobId }, + data: { verify_result: result }, + }) +} + export function registerVerifyTaskAgainstPlanTool(server: McpServer) { server.registerTool( 'verify_task_against_plan', { title: 'Verify task against plan', description: - 'Compare the frozen plan_snapshot (captured at claim time) against current ' + - 'task.implementation_plan, story logs, and commits. Returns a markdown report ' + - 'with per-AC ✓/✗/? heuristic checks and a drift-score. Read-only — demo users allowed.', + 'Run `git diff origin/main...HEAD` in the worktree and compare it against the ' + + 'frozen plan_snapshot captured at claim time. Returns ALIGNED|PARTIAL|EMPTY|DIVERGENT ' + + 'and saves verify_result on the active job. ' + + 'Call this BEFORE update_job_status("done"). ' + + 'If the result is EMPTY and task.verify_only is false, update_job_status("done") will be rejected.', inputSchema, - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: false }, }, - async ({ task_id }) => + async ({ task_id, worktree_path }) => withToolErrors(async () => { const auth = await getAuth() if (!auth) return toolError('Unauthorized') @@ -34,62 +53,44 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) { where: { id: task_id }, select: { id: true, - title: true, - implementation_plan: true, - story: { - select: { - id: true, - acceptance_criteria: true, - logs: { - orderBy: { created_at: 'asc' }, - select: { - type: true, - content: true, - commit_hash: true, - commit_message: true, - }, - }, - }, - }, + verify_only: true, claude_jobs: { - where: { status: { in: ['CLAIMED', 'RUNNING', 'DONE', 'FAILED'] } }, + where: { status: { in: ['CLAIMED', 'RUNNING'] } }, orderBy: { created_at: 'desc' }, take: 1, - select: { plan_snapshot: true }, + select: { id: true, plan_snapshot: true }, }, }, }) if (!task) return toolError(`Task ${task_id} not found`) - const latestJob = task.claude_jobs[0] ?? null - const planSnapshot = latestJob ? latestJob.plan_snapshot : null + const activeJob = task.claude_jobs[0] ?? null - const implementationLogs = task.story.logs - .filter((l) => l.type === 'IMPLEMENTATION_PLAN') - .map((l) => l.content) + let diff: string + try { + diff = await getDiffInWorktree(worktree_path) + } catch (err) { + return toolError( + `git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`, + ) + } - const commits = task.story.logs - .filter((l) => l.type === 'COMMIT') - .map((l) => ({ hash: l.commit_hash, message: l.commit_message })) - - const result = buildVerifyResult({ - taskId: task.id, - taskTitle: task.title, - planSnapshot, - currentPlan: task.implementation_plan, - acceptanceCriteriaText: task.story.acceptance_criteria, - implementationLogs, - commits, + const { result, reasoning } = classifyDiffAgainstPlan({ + diff, + plan: activeJob?.plan_snapshot ?? null, }) + if (activeJob) { + await saveVerifyResult(activeJob.id, result) + } + return toolJson({ - report: renderMarkdownReport(result), - task_id: result.taskId, - drift_score: result.driftScore, - ac_results: result.acceptanceCriteria, - plan_edited: result.planEdited, - has_baseline: result.hasBaseline, + result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent', + reasoning, + verify_only: task.verify_only, + task_id, + job_id: activeJob?.id ?? null, }) }), ) diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index d4e5be5..4aff070 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 @@ -19,14 +73,78 @@ const inputSchema = z.object({ wait_seconds: z.number().int().min(1).max(MAX_WAIT_SECONDS).default(300), }) -export async function resetStaleClaimedJobs(userId: string) { - await prisma.$executeRaw` +const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts' + +export async function resetStaleClaimedJobs(userId: string): Promise { + // Jobs that exceeded the retry limit → FAILED + const failedRows = await prisma.$queryRaw< + Array<{ id: string; task_id: string; product_id: string }> + >` UPDATE claude_jobs - SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL + SET status = 'FAILED', + finished_at = NOW(), + error = ${STALE_ERROR_MSG} WHERE user_id = ${userId} AND status = 'CLAIMED' AND claimed_at < NOW() - INTERVAL '30 minutes' + AND retry_count >= 2 + RETURNING id, task_id, product_id ` + + // Jobs under the retry limit → back to QUEUED, increment retry_count + const requeuedRows = await prisma.$queryRaw< + Array<{ id: string; task_id: string; product_id: string; retry_count: number }> + >` + UPDATE claude_jobs + SET status = 'QUEUED', + claimed_by_token_id = NULL, + claimed_at = NULL, + plan_snapshot = NULL, + retry_count = retry_count + 1 + WHERE user_id = ${userId} + AND status = 'CLAIMED' + AND claimed_at < NOW() - INTERVAL '30 minutes' + AND retry_count < 2 + RETURNING id, task_id, product_id, retry_count + ` + + if (failedRows.length === 0 && requeuedRows.length === 0) return + + // Notify UI via SSE for each transition (best-effort) + try { + const pg = new Client({ connectionString: process.env.DATABASE_URL }) + await pg.connect() + for (const j of failedRows) { + await pg.query('SELECT pg_notify($1, $2)', [ + 'scrum4me_changes', + JSON.stringify({ + type: 'claude_job_status', + job_id: j.id, + task_id: j.task_id, + user_id: userId, + product_id: j.product_id, + status: 'failed', + error: STALE_ERROR_MSG, + }), + ]) + } + for (const j of requeuedRows) { + await pg.query('SELECT pg_notify($1, $2)', [ + 'scrum4me_changes', + JSON.stringify({ + type: 'claude_job_status', + job_id: j.id, + task_id: j.task_id, + user_id: userId, + product_id: j.product_id, + status: 'queued', + }), + ]) + } + await pg.end() + } catch { + // non-fatal — status transitions are already persisted + } } export async function tryClaimJob( @@ -162,6 +280,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 +319,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 +365,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 { diff --git a/src/verify/classify.ts b/src/verify/classify.ts new file mode 100644 index 0000000..49991bc --- /dev/null +++ b/src/verify/classify.ts @@ -0,0 +1,125 @@ +export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT' + +export interface ClassifyResult { + result: VerifyResultValue + reasoning: string +} + +// Extract changed file paths from a unified diff ("+++ b/" lines). +function extractDiffPaths(diff: string): string[] { + const paths = new Set() + for (const line of diff.split('\n')) { + const m = line.match(/^\+\+\+ b\/(.+)$/) + if (m && m[1].trim() !== '/dev/null') paths.add(m[1].trim()) + } + return [...paths] +} + +// Extract file paths mentioned in a plan (backtick-quoted, parenthesised, or bullet-list headings). +function extractPlanPaths(plan: string): string[] { + const paths = new Set() + + const backtickRe = /`([^`\s][^`]*[^`\s]|[^`\s])`/g + let m: RegExpExecArray | null + while ((m = backtickRe.exec(plan)) !== null) { + const p = m[1].trim() + if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p) + } + + const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm + while ((m = bulletRe.exec(plan)) !== null) { + paths.add(`${m[1]}.${m[2]}`) + } + + return [...paths] +} + +// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts". +function pathMatches(planPath: string, diffPaths: string[]): boolean { + const norm = planPath.replace(/\\/g, '/') + return diffPaths.some((dp) => { + const ndp = dp.replace(/\\/g, '/') + return ndp === norm || ndp.endsWith(`/${norm}`) || norm.endsWith(`/${ndp}`) + }) +} + +/** + * Classify a unified git diff against an implementation plan. + * Returns a VerifyResult (ALIGNED|PARTIAL|EMPTY|DIVERGENT) plus human-readable reasoning. + * + * v1 heuristic — no LLM required: + * - No file changes in diff → EMPTY + * - Plan empty, diff ≤50 changed lines → ALIGNED (targeted fix) + * - Plan empty, diff >50 changed lines → DIVERGENT (too large to trust) + * - Plan paths < 100% covered in diff → PARTIAL + * - Plan paths 100% covered, diff ≥3× more paths → DIVERGENT (scope creep) + * - Plan paths 100% covered, diff <3× more paths → ALIGNED + */ +export function classifyDiffAgainstPlan(opts: { + diff: string + plan: string | null +}): ClassifyResult { + const { diff, plan } = opts + + const diffPaths = extractDiffPaths(diff) + if (diffPaths.length === 0) { + return { result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' } + } + + const changedLines = diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')).length + + if (!plan || plan.trim().length === 0) { + if (changedLines > 50) { + return { + result: 'DIVERGENT', + reasoning: `Geen plan-baseline aanwezig; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`, + } + } + return { + result: 'ALIGNED', + reasoning: `Geen plan-baseline; ${diffPaths.length} bestand(en) gewijzigd — kleine gerichte wijziging.`, + } + } + + const planPaths = extractPlanPaths(plan) + + if (planPaths.length === 0) { + if (changedLines > 50) { + return { + result: 'DIVERGENT', + reasoning: `Plan vermeldt geen specifieke paden; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`, + } + } + return { + result: 'ALIGNED', + reasoning: `Plan vermeldt geen specifieke paden; ${diffPaths.length} bestand(en) gewijzigd.`, + } + } + + const covered = planPaths.filter((pp) => pathMatches(pp, diffPaths)) + const coverage = covered.length / planPaths.length + const ratio = diffPaths.length / planPaths.length + + if (coverage < 1) { + const missing = planPaths.filter((pp) => !pathMatches(pp, diffPaths)) + const missingStr = missing.slice(0, 3).join(', ') + (missing.length > 3 ? ` + ${missing.length - 3} meer` : '') + return { + result: 'PARTIAL', + reasoning: `${covered.length}/${planPaths.length} plan-paden aanwezig in diff. Ontbrekend: ${missingStr}.`, + } + } + + if (ratio >= 3) { + const extra = diffPaths.filter((dp) => !planPaths.some((pp) => pathMatches(pp, [dp]))) + const extraStr = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ` + ${extra.length - 3} meer` : '') + return { + result: 'DIVERGENT', + reasoning: `Alle ${planPaths.length} plan-paden aanwezig, maar diff bevat ${diffPaths.length} paden (${ratio.toFixed(1)}x). Extra: ${extraStr}.`, + } + } + + return { + result: 'ALIGNED', + reasoning: `Alle ${planPaths.length} plan-paden aanwezig in diff (${diffPaths.length} totaal; ${ratio.toFixed(1)}x).`, + } +}