/**
* complete_task MCP Tool
*
* Complete a task and compute file changes via Git diff.
* This is the MOST CRITICAL tool - it calculates the union of committed + working tree changes.
*/
import { z } from 'zod'
import { prisma } from '../db.js'
import {
computeGitDiff,
verifyScope,
type GitSnapshotData,
} from '../utils/git-snapshot.js'
import {
toJsonArray,
fromJsonArray,
fromJsonObject,
} from '../utils/json-fields.js'
import { updateWorkflowMetrics } from '../utils/workflow-metrics.js'
import { emitTaskUpdated } from '../websocket/index.js'
import { NotFoundError, ValidationError } from '../utils/errors.js'
import {
taskStatusMap,
testsStatusMap,
TaskStatus,
} from '../types/enums.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
// Zod schema for validation
const completeTaskSchema = z.object({
task_id: z.string().min(1),
status: z.enum(['success', 'partial_success', 'failed']),
outcome: z.object({
summary: z.string().min(1),
achievements: z.array(z.string()).optional(),
limitations: z.array(z.string()).optional(),
manual_review_needed: z.boolean().optional(),
manual_review_reason: z.string().optional(),
next_steps: z.array(z.string()).optional(),
}),
metadata: z
.object({
packages_added: z.array(z.string()).optional(),
packages_removed: z.array(z.string()).optional(),
commands_executed: z.array(z.string()).optional(),
tests_status: z.enum(['passed', 'failed', 'not_run']).optional(),
tokens_input: z.number().int().nonnegative().optional(),
tokens_output: z.number().int().nonnegative().optional(),
})
.optional(),
})
// MCP Tool definition
export const completeTaskTool = {
name: 'complete_task',
description: 'Complete a task and compute file changes via Git diff',
inputSchema: {
type: 'object' as const,
properties: {
task_id: {
type: 'string',
description: 'Task ID to complete',
},
status: {
type: 'string',
enum: ['success', 'partial_success', 'failed'],
description: 'Final status of the task',
},
outcome: {
type: 'object',
properties: {
summary: {
type: 'string',
description: 'Summary of what was accomplished (2-4 sentences)',
},
achievements: {
type: 'array',
items: { type: 'string' },
description: 'Concrete achievements (empty array if none)',
},
limitations: {
type: 'array',
items: { type: 'string' },
description: 'Limitations/compromises (empty array if none)',
},
manual_review_needed: {
type: 'boolean',
description: 'Does a human need to review before continuing?',
},
manual_review_reason: {
type: 'string',
description: 'Why manual review is needed',
},
next_steps: {
type: 'array',
items: { type: 'string' },
description: 'Suggested next steps (optional)',
},
},
required: ['summary'],
},
metadata: {
type: 'object',
properties: {
packages_added: {
type: 'array',
items: { type: 'string' },
},
packages_removed: {
type: 'array',
items: { type: 'string' },
},
commands_executed: {
type: 'array',
items: { type: 'string' },
},
tests_status: {
type: 'string',
enum: ['passed', 'failed', 'not_run'],
},
tokens_input: {
type: 'number',
description: 'Number of input tokens used by this task',
},
tokens_output: {
type: 'number',
description: 'Number of output tokens generated by this task',
},
},
},
},
required: ['task_id', 'status', 'outcome'],
},
}
// Handler
export async function handleCompleteTask(
args: unknown
): Promise<CallToolResult> {
// Validate input
const validated = completeTaskSchema.parse(args)
// Fetch task with snapshot data
const task = await prisma.task.findUnique({
where: { id: validated.task_id },
})
if (!task) {
throw new NotFoundError(`Task not found: ${validated.task_id}`)
}
if (task.status !== TaskStatus.IN_PROGRESS) {
throw new ValidationError(
`Task is not in progress: ${task.status}`
)
}
// Check if this task has any subtasks still in progress
const incompleteSubtasks = await prisma.task.findMany({
where: {
parentTaskId: validated.task_id,
status: TaskStatus.IN_PROGRESS,
},
select: { id: true, name: true },
})
if (incompleteSubtasks.length > 0) {
const subtaskNames = incompleteSubtasks.map((t) => t.name).join(', ')
throw new ValidationError(
`Cannot complete task: ${incompleteSubtasks.length} subtask(s) still in progress (${subtaskNames})`
)
}
// Calculate completion time
const completedAt = new Date()
const durationMs = completedAt.getTime() - task.startedAt.getTime()
// Compute Git diff (CRITICAL: Union of committed + working tree changes)
let filesAdded: string[] = []
let filesModified: string[] = []
let filesDeleted: string[] = []
if (task.snapshotType === 'git' && task.snapshotData) {
// SQLite: snapshotData is stored as JSON string
const snapshotData = fromJsonObject<GitSnapshotData>(task.snapshotData)
if (snapshotData?.gitHash) {
const diff = await computeGitDiff(snapshotData.gitHash)
filesAdded = diff.added
filesModified = diff.modified
filesDeleted = diff.deleted
}
}
// Verify scope
const allChangedFiles = [...filesAdded, ...filesModified, ...filesDeleted]
// SQLite: areas is stored as JSON string
const taskAreas = fromJsonArray<string>(task.areas)
const scopeVerification = verifyScope(allChangedFiles, taskAreas)
// Map status to Prisma enum
const taskStatus = taskStatusMap[validated.status]
if (!taskStatus) {
throw new ValidationError(`Invalid task status: ${validated.status}`)
}
// Map tests status if provided
let testsStatus = null
if (validated.metadata?.tests_status) {
testsStatus = testsStatusMap[validated.metadata.tests_status] ?? null
}
// Update task in database
const updatedTask = await prisma.task.update({
where: { id: validated.task_id },
data: {
status: taskStatus,
completedAt,
durationMs,
summary: validated.outcome.summary,
achievements: toJsonArray(validated.outcome.achievements),
limitations: toJsonArray(validated.outcome.limitations),
manualReviewNeeded: validated.outcome.manual_review_needed ?? false,
manualReviewReason: validated.outcome.manual_review_reason,
nextSteps: toJsonArray(validated.outcome.next_steps),
packagesAdded: toJsonArray(validated.metadata?.packages_added),
packagesRemoved: toJsonArray(validated.metadata?.packages_removed),
commandsExecuted: toJsonArray(validated.metadata?.commands_executed),
testsStatus,
tokensInput: validated.metadata?.tokens_input,
tokensOutput: validated.metadata?.tokens_output,
filesAdded: toJsonArray(filesAdded),
filesModified: toJsonArray(filesModified),
filesDeleted: toJsonArray(filesDeleted),
scopeMatch: scopeVerification.scopeMatch,
unexpectedFiles: toJsonArray(scopeVerification.unexpectedFiles),
warnings: toJsonArray(scopeVerification.warnings),
},
})
// Emit WebSocket event for real-time UI update
emitTaskUpdated(updatedTask, task.workflowId)
// Build response
const response: Record<string, unknown> = {
task_id: updatedTask.id,
workflow_id: task.workflowId,
duration_seconds: Math.round(durationMs / 1000),
files_changed: {
added: filesAdded,
modified: filesModified,
deleted: filesDeleted,
},
verification: {
scope_match: scopeVerification.scopeMatch,
unexpected_files: scopeVerification.unexpectedFiles,
warnings: scopeVerification.warnings,
},
}
// Update workflow metrics (but NOT status - only orchestrator can complete workflow)
await updateWorkflowMetrics(task.workflowId)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
}
}