Tm start (#1200)
Co-authored-by: Max Tuzzolino <maxtuzz@Maxs-MacBook-Pro.local> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Max Tuzzolino <max.tuzsmith@gmail.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
This commit is contained in:
308
packages/tm-core/src/services/task-execution-service.ts
Normal file
308
packages/tm-core/src/services/task-execution-service.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @fileoverview TaskExecutionService for handling task execution business logic
|
||||
* Extracted from CLI start command to be reusable across CLI and extension
|
||||
*/
|
||||
|
||||
import type { Task } from '../types/index.js';
|
||||
import type { TaskService } from './task-service.js';
|
||||
|
||||
export interface StartTaskOptions {
|
||||
subtaskId?: string;
|
||||
dryRun?: boolean;
|
||||
updateStatus?: boolean;
|
||||
force?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export interface StartTaskResult {
|
||||
task: Task | null;
|
||||
found: boolean;
|
||||
started: boolean;
|
||||
subtaskId?: string;
|
||||
subtask?: any;
|
||||
error?: string;
|
||||
executionOutput?: string;
|
||||
/** Command to execute (for CLI to run directly) */
|
||||
command?: {
|
||||
executable: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConflictCheckResult {
|
||||
canProceed: boolean;
|
||||
conflictingTasks: Task[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskExecutionService handles the business logic for starting and executing tasks
|
||||
*/
|
||||
export class TaskExecutionService {
|
||||
constructor(private taskService: TaskService) {}
|
||||
|
||||
/**
|
||||
* Start working on a task with comprehensive business logic
|
||||
*/
|
||||
async startTask(
|
||||
taskId: string,
|
||||
options: StartTaskOptions = {}
|
||||
): Promise<StartTaskResult> {
|
||||
try {
|
||||
// Handle subtask IDs by extracting parent task ID
|
||||
const { parentId, subtaskId } = this.parseTaskId(taskId);
|
||||
|
||||
// Check for in-progress task conflicts
|
||||
if (!options.force) {
|
||||
const conflictCheck = await this.checkInProgressConflicts(taskId);
|
||||
if (!conflictCheck.canProceed) {
|
||||
return {
|
||||
task: null,
|
||||
found: false,
|
||||
started: false,
|
||||
error: `Conflicting tasks in progress: ${conflictCheck.conflictingTasks.map((t) => `#${t.id}: ${t.title}`).join(', ')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual task (parent task if dealing with subtask)
|
||||
const task = await this.taskService.getTask(parentId);
|
||||
if (!task) {
|
||||
return {
|
||||
task: null,
|
||||
found: false,
|
||||
started: false,
|
||||
error: `Task ${parentId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Find the specific subtask if provided
|
||||
let subtask = undefined;
|
||||
if (subtaskId && task.subtasks) {
|
||||
subtask = task.subtasks.find((st) => String(st.id) === subtaskId);
|
||||
}
|
||||
|
||||
// Update task status to in-progress if not disabled
|
||||
if (options.updateStatus && !options.dryRun) {
|
||||
try {
|
||||
await this.taskService.updateTaskStatus(parentId, 'in-progress');
|
||||
} catch (error) {
|
||||
// Log but don't fail - status update is not critical
|
||||
console.warn(
|
||||
`Could not update task status: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare execution command instead of executing directly
|
||||
let started = false;
|
||||
let executionOutput = 'Task ready to execute';
|
||||
let command = undefined;
|
||||
|
||||
if (!options.dryRun) {
|
||||
// Prepare the command for execution by the CLI
|
||||
command = await this.prepareExecutionCommand(task, subtask);
|
||||
started = !!command; // Command prepared successfully
|
||||
executionOutput = command
|
||||
? `Command prepared: ${command.executable} ${command.args.join(' ')}`
|
||||
: 'Failed to prepare execution command';
|
||||
} else {
|
||||
// For dry-run, just show that we would execute
|
||||
started = true;
|
||||
executionOutput = 'Dry run - task would be executed';
|
||||
// Also prepare command for dry run display
|
||||
command = await this.prepareExecutionCommand(task, subtask);
|
||||
}
|
||||
|
||||
return {
|
||||
task,
|
||||
found: true,
|
||||
started,
|
||||
subtaskId,
|
||||
subtask,
|
||||
executionOutput,
|
||||
command: command || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
task: null,
|
||||
found: false,
|
||||
started: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for existing in-progress tasks and determine conflicts
|
||||
*/
|
||||
async checkInProgressConflicts(
|
||||
targetTaskId: string
|
||||
): Promise<ConflictCheckResult> {
|
||||
const allTasks = await this.taskService.getTaskList();
|
||||
const inProgressTasks = allTasks.tasks.filter(
|
||||
(task) => task.status === 'in-progress'
|
||||
);
|
||||
|
||||
// If the target task is already in-progress, that's fine
|
||||
const targetTaskInProgress = inProgressTasks.find(
|
||||
(task) => task.id === targetTaskId
|
||||
);
|
||||
if (targetTaskInProgress) {
|
||||
return { canProceed: true, conflictingTasks: [] };
|
||||
}
|
||||
|
||||
// Check if target is a subtask and its parent is in-progress
|
||||
const isSubtask = targetTaskId.includes('.');
|
||||
if (isSubtask) {
|
||||
const parentTaskId = targetTaskId.split('.')[0];
|
||||
const parentInProgress = inProgressTasks.find(
|
||||
(task) => task.id === parentTaskId
|
||||
);
|
||||
if (parentInProgress) {
|
||||
return { canProceed: true, conflictingTasks: [] }; // Allow subtasks when parent is in-progress
|
||||
}
|
||||
}
|
||||
|
||||
// Check if other unrelated tasks are in-progress
|
||||
const conflictingTasks = inProgressTasks.filter((task) => {
|
||||
if (task.id === targetTaskId) return false;
|
||||
|
||||
// If target is a subtask, exclude its parent from conflicts
|
||||
if (isSubtask) {
|
||||
const parentTaskId = targetTaskId.split('.')[0];
|
||||
if (task.id === parentTaskId) return false;
|
||||
}
|
||||
|
||||
// If the in-progress task is a subtask of our target parent, exclude it
|
||||
if (task.id.toString().includes('.')) {
|
||||
const taskParentId = task.id.toString().split('.')[0];
|
||||
if (isSubtask && taskParentId === targetTaskId.split('.')[0]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (conflictingTasks.length > 0) {
|
||||
return {
|
||||
canProceed: false,
|
||||
conflictingTasks,
|
||||
reason: 'Other tasks are already in progress'
|
||||
};
|
||||
}
|
||||
|
||||
return { canProceed: true, conflictingTasks: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available task to start
|
||||
*/
|
||||
async getNextAvailableTask(): Promise<string | null> {
|
||||
const nextTask = await this.taskService.getNextTask();
|
||||
return nextTask?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task ID to determine if it's a subtask and extract components
|
||||
*/
|
||||
private parseTaskId(taskId: string): {
|
||||
parentId: string;
|
||||
subtaskId?: string;
|
||||
} {
|
||||
if (taskId.includes('.')) {
|
||||
const [parentId, subtaskId] = taskId.split('.');
|
||||
return { parentId, subtaskId };
|
||||
}
|
||||
return { parentId: taskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task can be started (no conflicts)
|
||||
*/
|
||||
async canStartTask(taskId: string, force = false): Promise<boolean> {
|
||||
if (force) return true;
|
||||
|
||||
const conflictCheck = await this.checkInProgressConflicts(taskId);
|
||||
return conflictCheck.canProceed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare execution command for the CLI to run
|
||||
*/
|
||||
private async prepareExecutionCommand(
|
||||
task: Task,
|
||||
subtask?: any
|
||||
): Promise<{ executable: string; args: string[]; cwd: string } | null> {
|
||||
try {
|
||||
// Format the task into a prompt
|
||||
const taskPrompt = this.formatTaskPrompt(task, subtask);
|
||||
|
||||
// Use claude command - could be extended for other executors
|
||||
const executable = 'claude';
|
||||
const args = [taskPrompt];
|
||||
const cwd = process.cwd(); // or could get from project root
|
||||
|
||||
return { executable, args, cwd };
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to prepare execution command: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format task into a prompt suitable for execution
|
||||
*/
|
||||
private formatTaskPrompt(task: Task, subtask?: any): string {
|
||||
const workItem = subtask || task;
|
||||
const itemType = subtask ? 'Subtask' : 'Task';
|
||||
const itemId = subtask ? `${task.id}.${subtask.id}` : task.id;
|
||||
|
||||
let prompt = `${itemType} #${itemId}: ${workItem.title}\n\n`;
|
||||
|
||||
if (workItem.description) {
|
||||
prompt += `Description:\n${workItem.description}\n\n`;
|
||||
}
|
||||
|
||||
if (workItem.details) {
|
||||
prompt += `Implementation Details:\n${workItem.details}\n\n`;
|
||||
}
|
||||
|
||||
if (workItem.testStrategy) {
|
||||
prompt += `Test Strategy:\n${workItem.testStrategy}\n\n`;
|
||||
}
|
||||
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
prompt += `Dependencies: ${task.dependencies.join(', ')}\n\n`;
|
||||
}
|
||||
|
||||
prompt += `Please help me implement this ${itemType.toLowerCase()}.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task with subtask resolution
|
||||
*/
|
||||
async getTaskWithSubtask(
|
||||
taskId: string
|
||||
): Promise<{ task: Task | null; subtask?: any; subtaskId?: string }> {
|
||||
const { parentId, subtaskId } = this.parseTaskId(taskId);
|
||||
|
||||
const task = await this.taskService.getTask(parentId);
|
||||
if (!task) {
|
||||
return { task: null };
|
||||
}
|
||||
|
||||
if (subtaskId && task.subtasks) {
|
||||
const subtask = task.subtasks.find((st) => String(st.id) === subtaskId);
|
||||
return { task, subtask, subtaskId };
|
||||
}
|
||||
|
||||
return { task };
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export class TaskService {
|
||||
tasks,
|
||||
total: rawTasks.length,
|
||||
filtered: filteredEntities.length,
|
||||
tag: options.tag, // Only include tag if explicitly provided
|
||||
tag: tag, // Return the actual tag being used (either explicitly provided or active tag)
|
||||
storageType: this.getStorageType()
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -219,43 +219,127 @@ export class TaskService {
|
||||
|
||||
/**
|
||||
* Get next available task to work on
|
||||
* Prioritizes eligible subtasks from in-progress parent tasks before falling back to top-level tasks
|
||||
*/
|
||||
async getNextTask(tag?: string): Promise<Task | null> {
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
filter: {
|
||||
status: ['pending', 'in-progress']
|
||||
status: ['pending', 'in-progress', 'done']
|
||||
}
|
||||
});
|
||||
|
||||
// Find tasks with no dependencies or all dependencies satisfied
|
||||
const completedIds = new Set(
|
||||
result.tasks.filter((t) => t.status === 'done').map((t) => t.id)
|
||||
);
|
||||
const allTasks = result.tasks;
|
||||
const priorityValues = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
|
||||
const availableTasks = result.tasks.filter((task) => {
|
||||
if (task.status === 'done' || task.status === 'blocked') {
|
||||
return false;
|
||||
// Helper to convert subtask dependencies to full dotted notation
|
||||
const toFullSubId = (
|
||||
parentId: string,
|
||||
maybeDotId: string | number
|
||||
): string => {
|
||||
if (typeof maybeDotId === 'string' && maybeDotId.includes('.')) {
|
||||
return maybeDotId;
|
||||
}
|
||||
return `${parentId}.${maybeDotId}`;
|
||||
};
|
||||
|
||||
if (!task.dependencies || task.dependencies.length === 0) {
|
||||
return true;
|
||||
// Build completed IDs set (both tasks and subtasks)
|
||||
const completedIds = new Set<string>();
|
||||
allTasks.forEach((t) => {
|
||||
if (t.status === 'done') {
|
||||
completedIds.add(String(t.id));
|
||||
}
|
||||
if (Array.isArray(t.subtasks)) {
|
||||
t.subtasks.forEach((st) => {
|
||||
if (st.status === 'done') {
|
||||
completedIds.add(`${t.id}.${st.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return task.dependencies.every((depId) =>
|
||||
completedIds.has(depId.toString())
|
||||
);
|
||||
});
|
||||
|
||||
// Sort by priority
|
||||
availableTasks.sort((a, b) => {
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const aPriority = priorityOrder[a.priority || 'medium'];
|
||||
const bPriority = priorityOrder[b.priority || 'medium'];
|
||||
return aPriority - bPriority;
|
||||
// 1) Look for eligible subtasks from in-progress parent tasks
|
||||
const candidateSubtasks: Array<Task & { parentId?: string }> = [];
|
||||
|
||||
allTasks
|
||||
.filter((t) => t.status === 'in-progress' && Array.isArray(t.subtasks))
|
||||
.forEach((parent) => {
|
||||
parent.subtasks!.forEach((st) => {
|
||||
const stStatus = (st.status || 'pending').toLowerCase();
|
||||
if (stStatus !== 'pending' && stStatus !== 'in-progress') return;
|
||||
|
||||
const fullDeps =
|
||||
st.dependencies?.map((d) => toFullSubId(String(parent.id), d)) ??
|
||||
[];
|
||||
const depsSatisfied =
|
||||
fullDeps.length === 0 ||
|
||||
fullDeps.every((depId) => completedIds.has(String(depId)));
|
||||
|
||||
if (depsSatisfied) {
|
||||
candidateSubtasks.push({
|
||||
id: `${parent.id}.${st.id}`,
|
||||
title: st.title || `Subtask ${st.id}`,
|
||||
status: st.status || 'pending',
|
||||
priority: st.priority || parent.priority || 'medium',
|
||||
dependencies: fullDeps,
|
||||
parentId: String(parent.id),
|
||||
description: st.description,
|
||||
details: st.details,
|
||||
testStrategy: st.testStrategy,
|
||||
subtasks: []
|
||||
} as Task & { parentId: string });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (candidateSubtasks.length > 0) {
|
||||
// Sort by priority → dependency count → parent ID → subtask ID
|
||||
candidateSubtasks.sort((a, b) => {
|
||||
const pa =
|
||||
priorityValues[a.priority as keyof typeof priorityValues] ?? 2;
|
||||
const pb =
|
||||
priorityValues[b.priority as keyof typeof priorityValues] ?? 2;
|
||||
if (pb !== pa) return pb - pa;
|
||||
|
||||
if (a.dependencies!.length !== b.dependencies!.length) {
|
||||
return a.dependencies!.length - b.dependencies!.length;
|
||||
}
|
||||
|
||||
// Compare parent then subtask ID numerically
|
||||
const [aPar, aSub] = String(a.id).split('.').map(Number);
|
||||
const [bPar, bSub] = String(b.id).split('.').map(Number);
|
||||
if (aPar !== bPar) return aPar - bPar;
|
||||
return aSub - bSub;
|
||||
});
|
||||
|
||||
return candidateSubtasks[0];
|
||||
}
|
||||
|
||||
// 2) Fall back to top-level tasks (original logic)
|
||||
const eligibleTasks = allTasks.filter((task) => {
|
||||
const status = (task.status || 'pending').toLowerCase();
|
||||
if (status !== 'pending' && status !== 'in-progress') return false;
|
||||
|
||||
const deps = task.dependencies ?? [];
|
||||
return deps.every((depId) => completedIds.has(String(depId)));
|
||||
});
|
||||
|
||||
return availableTasks[0] || null;
|
||||
if (eligibleTasks.length === 0) return null;
|
||||
|
||||
// Sort by priority → dependency count → task ID
|
||||
const nextTask = eligibleTasks.sort((a, b) => {
|
||||
const pa = priorityValues[a.priority as keyof typeof priorityValues] ?? 2;
|
||||
const pb = priorityValues[b.priority as keyof typeof priorityValues] ?? 2;
|
||||
if (pb !== pa) return pb - pa;
|
||||
|
||||
const da = (a.dependencies ?? []).length;
|
||||
const db = (b.dependencies ?? []).length;
|
||||
if (da !== db) return da - db;
|
||||
|
||||
return Number(a.id) - Number(b.id);
|
||||
})[0];
|
||||
|
||||
return nextTask;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user