Files
Aperant/auto-claude-ui/src/main/ipc-handlers/task/execution-handlers.ts
T
2025-12-18 14:49:07 +01:00

636 lines
23 KiB
TypeScript

import { ipcMain, BrowserWindow } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { AgentManager } from '../../agent';
import { fileWatcher } from '../../file-watcher';
import { findTaskAndProject } from './shared';
import { checkGitStatus } from '../../project-initializer';
/**
* Register task execution handlers (start, stop, review, status management, recovery)
*/
export function registerTaskExecutionHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
/**
* Start a task
*/
ipcMain.on(
IPC_CHANNELS.TASK_START,
(_, taskId: string, _options?: TaskStartOptions) => {
console.warn('[TASK_START] Received request for taskId:', taskId);
const mainWindow = getMainWindow();
if (!mainWindow) {
console.warn('[TASK_START] No main window found');
return;
}
// Find task and project
const { task, project } = findTaskAndProject(taskId);
if (!task || !project) {
console.warn('[TASK_START] Task or project not found for taskId:', taskId);
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
'Task or project not found'
);
return;
}
// Check git status - Auto Claude requires git for worktree-based builds
const gitStatus = checkGitStatus(project.path);
if (!gitStatus.isGitRepo) {
console.warn('[TASK_START] Project is not a git repository:', project.path);
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
'Git repository required. Please run "git init" in your project directory. Auto Claude uses git worktrees for isolated builds.'
);
return;
}
if (!gitStatus.hasCommits) {
console.warn('[TASK_START] Git repository has no commits:', project.path);
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
'Git repository has no commits. Please make an initial commit first (git add . && git commit -m "Initial commit").'
);
return;
}
console.warn('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length);
// Start file watcher for this task
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = path.join(
project.path,
specsBaseDir,
task.specId
);
fileWatcher.watch(taskId, specDir);
// Check if spec.md exists (indicates spec creation was already done or in progress)
const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE);
const hasSpec = existsSync(specFilePath);
// Check if this task needs spec creation first (no spec file = not yet created)
// OR if it has a spec but no implementation plan subtasks (spec created, needs planning/building)
const needsSpecCreation = !hasSpec;
const needsImplementation = hasSpec && task.subtasks.length === 0;
console.warn('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation);
// Get base branch from project settings for worktree creation
const baseBranch = project.settings?.mainBranch;
if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir);
// Start spec creation process - pass the existing spec directory
// so spec_runner uses it instead of creating a new one
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
// Read the spec.md to get the task description
const _taskDescription = task.description || task.title;
try {
readFileSync(specFilePath, 'utf-8');
} catch {
// Use default description
}
console.warn('[TASK_START] Starting task execution (no subtasks) for:', task.specId);
// Start task execution which will create the implementation plan
// Note: No parallel mode for planning phase - parallel only makes sense with multiple subtasks
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false, // Sequential for planning phase
workers: 1,
baseBranch
}
);
} else {
// Task has subtasks, start normal execution
// Note: Parallel execution is handled internally by the agent, not via CLI flags
console.warn('[TASK_START] Starting task execution (has subtasks) for:', task.specId);
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1,
baseBranch
}
);
}
// Notify status change
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'in_progress'
);
}
);
/**
* Stop a task
*/
ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => {
agentManager.killTask(taskId);
fileWatcher.unwatch(taskId);
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'backlog'
);
}
});
/**
* Review a task (approve or reject)
*/
ipcMain.handle(
IPC_CHANNELS.TASK_REVIEW,
async (
_,
taskId: string,
approved: boolean,
feedback?: string
): Promise<IPCResult> => {
// Find task and project
const { task, project } = findTaskAndProject(taskId);
if (!task || !project) {
return { success: false, error: 'Task not found' };
}
// Check if dev mode is enabled for this project
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = path.join(
project.path,
specsBaseDir,
task.specId
);
if (approved) {
// Write approval to QA report
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
writeFileSync(
qaReportPath,
`# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`
);
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'done'
);
}
} else {
// Write feedback for QA fixer
const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md');
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
);
// Restart QA process with dev mode
agentManager.startQAProcess(taskId, project.path, task.specId);
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'in_progress'
);
}
}
return { success: true };
}
);
/**
* Update task status manually
*/
ipcMain.handle(
IPC_CHANNELS.TASK_UPDATE_STATUS,
async (
_,
taskId: string,
status: TaskStatus
): Promise<IPCResult> => {
// Validate status transition - 'done' can only be set through merge handler
// This prevents AI agents from bypassing the human review workflow
if (status === 'done') {
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'done' directly for task ${taskId}. Use merge workflow instead.`);
return {
success: false,
error: "Cannot set status to 'done' directly. Complete the human review and merge the worktree changes instead."
};
}
// Find task and project
const { task, project } = findTaskAndProject(taskId);
if (!task || !project) {
return { success: false, error: 'Task not found' };
}
// Get the spec directory
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = path.join(
project.path,
specsBaseDir,
task.specId
);
// Update implementation_plan.json if it exists
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
try {
if (existsSync(planPath)) {
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
// Store the exact UI status - project-store.ts will map it back
plan.status = status;
// Also store mapped version for Python compatibility
// Note: 'done' is blocked at the start of this handler - only set via merge workflow
plan.planStatus = status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: 'pending';
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
} else {
// If no implementation plan exists yet, create a basic one
// Note: 'done' status is blocked at the start of this handler
const plan = {
feature: task.title,
description: task.description || '',
created_at: task.createdAt.toISOString(),
updated_at: new Date().toISOString(),
status: status, // Store exact UI status for persistence
planStatus: status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: 'pending',
phases: []
};
// Ensure spec directory exists
if (!existsSync(specDir)) {
mkdirSync(specDir, { recursive: true });
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
}
// Auto-start task when status changes to 'in_progress' and no process is running
if (status === 'in_progress' && !agentManager.isRunning(taskId)) {
const mainWindow = getMainWindow();
// Check git status before auto-starting
const gitStatusCheck = checkGitStatus(project.path);
if (!gitStatusCheck.isGitRepo || !gitStatusCheck.hasCommits) {
console.warn('[TASK_UPDATE_STATUS] Git check failed, cannot auto-start task');
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
gitStatusCheck.error || 'Git repository with commits required to run tasks.'
);
}
return { success: false, error: gitStatusCheck.error || 'Git repository required' };
}
console.warn('[TASK_UPDATE_STATUS] Auto-starting task:', taskId);
// Start file watcher for this task
fileWatcher.watch(taskId, specDir);
// Check if spec.md exists
const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE);
const hasSpec = existsSync(specFilePath);
const needsSpecCreation = !hasSpec;
const needsImplementation = hasSpec && task.subtasks.length === 0;
console.warn('[TASK_UPDATE_STATUS] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation);
if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1
}
);
} else {
// Task has subtasks, start normal execution
// Note: Parallel execution is handled internally by the agent
console.warn('[TASK_UPDATE_STATUS] Starting task execution (has subtasks) for:', task.specId);
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1
}
);
}
// Notify renderer about status change
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'in_progress'
);
}
}
return { success: true };
} catch (error) {
console.error('Failed to update task status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update task status'
};
}
}
);
/**
* Check if a task is actually running (has active process)
*/
ipcMain.handle(
IPC_CHANNELS.TASK_CHECK_RUNNING,
async (_, taskId: string): Promise<IPCResult<boolean>> => {
const isRunning = agentManager.isRunning(taskId);
return { success: true, data: isRunning };
}
);
/**
* Recover a stuck task (status says in_progress but no process running)
*/
ipcMain.handle(
IPC_CHANNELS.TASK_RECOVER_STUCK,
async (
_,
taskId: string,
options?: { targetStatus?: TaskStatus; autoRestart?: boolean }
): Promise<IPCResult<{ taskId: string; recovered: boolean; newStatus: TaskStatus; message: string; autoRestarted?: boolean }>> => {
const targetStatus = options?.targetStatus;
const autoRestart = options?.autoRestart ?? false;
// Check if task is actually running
const isActuallyRunning = agentManager.isRunning(taskId);
if (isActuallyRunning) {
return {
success: false,
error: 'Task is still running. Stop it first before recovering.',
data: {
taskId,
recovered: false,
newStatus: 'in_progress' as TaskStatus,
message: 'Task is still running'
}
};
}
// Find task and project
const { task, project } = findTaskAndProject(taskId);
if (!task || !project) {
return { success: false, error: 'Task not found' };
}
// Get the spec directory
const autoBuildDir = project.autoBuildPath || '.auto-claude';
const specDir = path.join(
project.path,
autoBuildDir,
'specs',
task.specId
);
// Update implementation_plan.json
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
try {
// Read the plan to analyze subtask progress
let plan: Record<string, unknown> | null = null;
if (existsSync(planPath)) {
const planContent = readFileSync(planPath, 'utf-8');
plan = JSON.parse(planContent);
}
// Determine the target status intelligently based on subtask progress
// If targetStatus is explicitly provided, use it; otherwise calculate from subtasks
let newStatus: TaskStatus = targetStatus || 'backlog';
if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) {
// Analyze subtask statuses to determine appropriate recovery status
const allSubtasks: Array<{ status: string }> = [];
for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) {
if (phase.subtasks && Array.isArray(phase.subtasks)) {
allSubtasks.push(...phase.subtasks);
}
}
if (allSubtasks.length > 0) {
const completedCount = allSubtasks.filter(s => s.status === 'completed').length;
const allCompleted = completedCount === allSubtasks.length;
if (allCompleted) {
// All subtasks completed - should go to review (ai_review or human_review based on source)
// For recovery, human_review is safer as it requires manual verification
newStatus = 'human_review';
} else if (completedCount > 0) {
// Some subtasks completed, some still pending - task is in progress
newStatus = 'in_progress';
}
// else: no subtasks completed, stay with 'backlog'
}
}
if (plan) {
// Update status
plan.status = newStatus;
plan.planStatus = newStatus === 'done' ? 'completed'
: newStatus === 'in_progress' ? 'in_progress'
: newStatus === 'ai_review' ? 'review'
: newStatus === 'human_review' ? 'review'
: 'pending';
plan.updated_at = new Date().toISOString();
// Add recovery note
plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`;
// Reset in_progress and failed subtask statuses to 'pending' so they can be retried
// Keep completed subtasks as-is so run.py can resume from where it left off
if (plan.phases && Array.isArray(plan.phases)) {
for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) {
if (phase.subtasks && Array.isArray(phase.subtasks)) {
for (const subtask of phase.subtasks) {
// Reset in_progress subtasks to pending (they were interrupted)
// Keep completed subtasks as-is so run.py can resume
if (subtask.status === 'in_progress') {
subtask.status = 'pending';
// Clear execution data to maintain consistency
delete subtask.actual_output;
delete subtask.started_at;
delete subtask.completed_at;
}
// Also reset failed subtasks so they can be retried
if (subtask.status === 'failed') {
subtask.status = 'pending';
// Clear execution data to maintain consistency
delete subtask.actual_output;
delete subtask.started_at;
delete subtask.completed_at;
}
}
}
}
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
}
// Stop file watcher if it was watching this task
fileWatcher.unwatch(taskId);
// Auto-restart the task if requested
let autoRestarted = false;
if (autoRestart && project) {
// Check git status before auto-restarting
const gitStatusForRestart = checkGitStatus(project.path);
if (!gitStatusForRestart.isGitRepo || !gitStatusForRestart.hasCommits) {
console.warn('[Recovery] Git check failed, cannot auto-restart task');
// Recovery succeeded but we can't restart without git
return {
success: true,
data: {
taskId,
recovered: true,
newStatus,
message: `Task recovered but cannot restart: ${gitStatusForRestart.error || 'Git repository with commits required.'}`,
autoRestarted: false
}
};
}
try {
// Set status to in_progress for the restart
newStatus = 'in_progress';
// Update plan status for restart
if (plan) {
plan.status = 'in_progress';
plan.planStatus = 'in_progress';
writeFileSync(planPath, JSON.stringify(plan, null, 2));
}
// Start the task execution
// Start file watcher for this task
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId);
fileWatcher.watch(taskId, specDirForWatcher);
// Check if spec.md exists to determine whether to run spec creation or task execution
const specFilePath = path.join(specDirForWatcher, AUTO_BUILD_PATHS.SPEC_FILE);
const hasSpec = existsSync(specFilePath);
const needsSpecCreation = !hasSpec;
if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDirForWatcher, task.metadata);
} else {
// Spec exists - run task execution
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1
}
);
}
autoRestarted = true;
console.warn(`[Recovery] Auto-restarted task ${taskId}`);
} catch (restartError) {
console.error('Failed to auto-restart task after recovery:', restartError);
// Recovery succeeded but restart failed - still report success
}
}
// Notify renderer of status change
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
newStatus
);
}
return {
success: true,
data: {
taskId,
recovered: true,
newStatus,
message: autoRestarted
? 'Task recovered and restarted successfully'
: `Task recovered successfully and moved to ${newStatus}`,
autoRestarted
}
};
} catch (error) {
console.error('Failed to recover stuck task:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to recover task'
};
}
}
);
}