diff --git a/__tests__/job-heartbeat.test.ts b/__tests__/job-heartbeat.test.ts new file mode 100644 index 0000000..896f317 --- /dev/null +++ b/__tests__/job-heartbeat.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + $queryRaw: vi.fn(), + sprintRun: { findUnique: vi.fn() }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + $queryRaw: ReturnType + sprintRun: { findUnique: ReturnType } +} +const mockAuth = requireWriteAccess as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerJobHeartbeatTool(server as unknown as McpServer) + return server +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('job_heartbeat', () => { + it('returns 403-style error when no row matched (token mismatch / terminal)', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]) + const server = makeServer() + const result = (await server.call({ job_id: 'job-x' })) as { + content: { text: string }[] + isError?: boolean + } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i) + }) + + it('non-SPRINT job returns ok + lease_until without sprint fields', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-1', + lease_until: lease, + kind: 'TASK_IMPLEMENTATION', + sprint_run_id: null, + }, + ]) + const server = makeServer() + const result = (await server.call({ job_id: 'job-1' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ + ok: true, + job_id: 'job-1', + lease_until: lease.toISOString(), + sprint_run_status: null, + sprint_run_pause_reason: null, + }) + expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled() + }) + + it('SPRINT job returns sprint_run_status from sibling lookup', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-2', + lease_until: lease, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'sr-1', + }, + ]) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + status: 'PAUSED', + pause_context: { pause_reason: 'QUOTA_DEPLETED' }, + }) + + const server = makeServer() + const result = (await server.call({ job_id: 'job-2' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body).toMatchObject({ + ok: true, + sprint_run_status: 'PAUSED', + sprint_run_pause_reason: 'QUOTA_DEPLETED', + }) + }) + + it('SPRINT job tolerates missing pause_context', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-3', + lease_until: lease, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'sr-2', + }, + ]) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + status: 'RUNNING', + pause_context: null, + }) + + const server = makeServer() + const result = (await server.call({ job_id: 'job-3' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body.sprint_run_status).toBe('RUNNING') + expect(body.sprint_run_pause_reason).toBeNull() + }) +}) diff --git a/__tests__/update-job-status-sprint-gate.test.ts b/__tests__/update-job-status-sprint-gate.test.ts new file mode 100644 index 0000000..e96b94a --- /dev/null +++ b/__tests__/update-job-status-sprint-gate.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), + update: vi.fn(), + }, + story: { + count: vi.fn(), + }, + }, +})) + +import { prisma } from '../src/prisma.js' +import { + checkSprintVerifyGate, + finalizeSprintRunOnDone, +} from '../src/tools/update-job-status.js' + +type MockedPrisma = { + sprintTaskExecution: { findMany: ReturnType } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } + story: { count: ReturnType } +} + +const mocked = prisma as unknown as MockedPrisma + +const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.' + +function execRow(overrides: Record) { + return { + id: 'exec-' + Math.random().toString(36).slice(2, 8), + task_id: 't1', + order: 0, + status: 'DONE', + verify_result: 'ALIGNED', + verify_summary: null, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + verify_only_snapshot: false, + task: { code: 'TASK-1', title: 'Sample task' }, + ...overrides, + } +} + +describe('checkSprintVerifyGate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects when no executions exist (claim-bug)', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i) + }) + + it('blocks PENDING/RUNNING executions', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'PENDING' }), + execRow({ status: 'RUNNING' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) { + expect(r.error).toMatch(/PENDING/) + expect(r.error).toMatch(/RUNNING/) + } + }) + + it('blocks FAILED executions', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'FAILED' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/FAILED/) + }) + + it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/SKIPPED/) + }) + + it('allows SKIPPED when verify_required_snapshot=ANY', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }), + ]) + expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) + }) + + it('runs per-row gate for DONE executions', async () => { + // PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ + status: 'DONE', + verify_result: 'PARTIAL', + verify_summary: null, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/DONE-gate/) + }) + + it('passes when all DONE rows pass per-row gate', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ verify_result: 'ALIGNED' }), + execRow({ + verify_result: 'PARTIAL', + verify_summary: LONG_SUMMARY, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + }), + ]) + expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) + }) + + it('aggregates multiple blockers in one error message', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }), + execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) { + expect(r.error).toMatch(/2 task\(s\) blokkeren/) + expect(r.error).toMatch(/A: a/) + expect(r.error).toMatch(/B: b/) + } + }) +}) + +describe('finalizeSprintRunOnDone', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('no-op when SprintRun already DONE (idempotent)', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'DONE', + sprint_id: 's1', + }) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('no-op when SprintRun does not exist', async () => { + mocked.sprintRun.findUnique.mockResolvedValue(null) + await finalizeSprintRunOnDone('sr-x') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('no-op when stories still open', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'RUNNING', + sprint_id: 's1', + }) + mocked.story.count.mockResolvedValue(2) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('sets SprintRun → DONE when all stories DONE/FAILED', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'RUNNING', + sprint_id: 's1', + }) + mocked.story.count.mockResolvedValue(0) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).toHaveBeenCalledWith({ + where: { id: 'sr-1' }, + data: expect.objectContaining({ + status: 'DONE', + finished_at: expect.any(Date), + }), + }) + }) +}) diff --git a/__tests__/update-task-execution.test.ts b/__tests__/update-task-execution.test.ts new file mode 100644 index 0000000..a893650 --- /dev/null +++ b/__tests__/update-task-execution.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + sprintTaskExecution: { + findUnique: ReturnType + update: ReturnType + } +} +const mockAuth = requireWriteAccess as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerUpdateTaskExecutionTool(server as unknown as McpServer) + return server +} + +function execRecord(overrides: Record = {}) { + return { + id: 'exec-1', + sprint_job_id: 'job-1', + sprint_job: { + claimed_by_token_id: TOKEN_ID, + status: 'CLAIMED', + kind: 'SPRINT_IMPLEMENTATION', + }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('update_task_execution', () => { + it('rejects when execution not found', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) + const server = makeServer() + const result = (await server.call({ + execution_id: 'missing', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found/i) + }) + + it('rejects wrong job-kind', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/) + }) + + it('rejects when token does not own the job', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/Forbidden/) + }) + + it('rejects when job is in terminal state', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'DONE', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/terminal/) + }) + + it('writes started_at on RUNNING', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'RUNNING', + base_sha: null, + head_sha: null, + verify_result: null, + verify_summary: null, + skip_reason: null, + started_at: new Date(), + finished_at: null, + }) + + const server = makeServer() + await server.call({ execution_id: 'exec-1', status: 'RUNNING' }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.status).toBe('RUNNING') + expect(updateCall.data.started_at).toBeInstanceOf(Date) + expect(updateCall.data.finished_at).toBeUndefined() + }) + + it('writes finished_at on DONE/FAILED/SKIPPED', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'DONE', + base_sha: 'sha-base', + head_sha: 'sha-head', + verify_result: null, + verify_summary: null, + skip_reason: null, + started_at: new Date(), + finished_at: new Date(), + }) + + const server = makeServer() + await server.call({ + execution_id: 'exec-1', + status: 'DONE', + head_sha: 'sha-head', + }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.status).toBe('DONE') + expect(updateCall.data.finished_at).toBeInstanceOf(Date) + expect(updateCall.data.head_sha).toBe('sha-head') + }) + + it('persists skip_reason on SKIPPED', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'SKIPPED', + base_sha: null, + head_sha: null, + verify_result: null, + verify_summary: null, + skip_reason: 'no-op task', + started_at: null, + finished_at: new Date(), + }) + + const server = makeServer() + await server.call({ + execution_id: 'exec-1', + status: 'SKIPPED', + skip_reason: 'no-op task', + }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.skip_reason).toBe('no-op task') + expect(updateCall.data.finished_at).toBeInstanceOf(Date) + }) +}) diff --git a/__tests__/verify-sprint-task.test.ts b/__tests__/verify-sprint-task.test.ts new file mode 100644 index 0000000..77bbc1b --- /dev/null +++ b/__tests__/verify-sprint-task.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +vi.mock('../src/verify/classify.js', () => ({ + classifyDiffAgainstPlan: vi.fn(), +})) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { classifyDiffAgainstPlan } from '../src/verify/classify.js' +import { execFile } from 'node:child_process' +import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + sprintTaskExecution: { + findUnique: ReturnType + findFirst: ReturnType + update: ReturnType + } +} +const mockAuth = requireWriteAccess as ReturnType +const mockClassify = classifyDiffAgainstPlan as ReturnType +const mockExecFile = execFile as unknown as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerVerifySprintTaskTool(server as unknown as McpServer) + return server +} + +function stubGitDiff(stdout: string) { + // promisify(execFile) calls (cmd, args, opts, cb) + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: null, result: { stdout: string; stderr: string }) => void, + ) => { + cb(null, { stdout, stderr: '' }) + }, + ) +} + +function execRecord(overrides: Record = {}) { + return { + id: 'exec-1', + sprint_job_id: 'job-1', + order: 0, + base_sha: 'sha-base', + plan_snapshot: 'frozen plan', + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + verify_only_snapshot: false, + sprint_job: { + claimed_by_token_id: TOKEN_ID, + status: 'CLAIMED', + kind: 'SPRINT_IMPLEMENTATION', + }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('verify_sprint_task', () => { + it('rejects when execution not found', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) + const server = makeServer() + const result = (await server.call({ + execution_id: 'missing', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found/i) + }) + + it('rejects wrong token', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/Forbidden/) + }) + + it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + stubGitDiff('diff --git a/x b/x\n+ change\n') + mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + summary: 'Refactor touched extra files for type narrowing.', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.result).toBe('partial') + expect(body.allowed_for_done).toBe(true) + expect(body.reason).toBeNull() + }) + + it('PARTIAL without summary returns allowed_for_done=false', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + stubGitDiff('diff --git a/x b/x\n') + mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.result).toBe('partial') + expect(body.allowed_for_done).toBe(false) + expect(body.reason).toMatch(/summary/i) + }) + + it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ verify_required_snapshot: 'ALIGNED' }), + ) + stubGitDiff('diff --git a/x b/x\n') + mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + summary: 'Long enough summary describing the deviation rationale clearly.', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.allowed_for_done).toBe(false) + expect(body.reason).toMatch(/ALIGNED/) + }) + + it('auto-fills base_sha from previous DONE execution head_sha', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ order: 1, base_sha: null }), + ) + mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({ + head_sha: 'prev-head-sha', + }) + stubGitDiff('diff\n') + mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.base_sha).toBe('prev-head-sha') + + // Persisted back to row + const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls + const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha') + expect(baseShaPersist).toBeDefined() + }) + + it('errors when base_sha cannot be derived (no prior DONE)', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ order: 2, base_sha: null }), + ) + mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/) + }) +})