diff --git a/.changeset/fix-cancelled-completion.md b/.changeset/fix-cancelled-completion.md new file mode 100644 index 00000000..79f8a30b --- /dev/null +++ b/.changeset/fix-cancelled-completion.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Fix completion percentage and dependency resolution to treat cancelled tasks as complete. Cancelled tasks now correctly count toward project completion (e.g., 14 done + 1 cancelled = 100%, not 93%) and satisfy dependencies for dependent tasks, preventing permanent blocks. diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index fc1ecfdd..401e2241 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -30,6 +30,7 @@ import { import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayError } from '../utils/error-handler.js'; import { getProjectRoot } from '../utils/project-root.js'; +import { isTaskComplete } from '../utils/task-status.js'; import * as ui from '../utils/ui.js'; /** @@ -344,12 +345,12 @@ export class ListTasksCommand extends Command { // Build set of completed task IDs (including subtasks) const completedIds = new Set(); tasks.forEach((t) => { - if (t.status === 'done' || t.status === 'completed') { + if (isTaskComplete(t.status)) { completedIds.add(String(t.id)); } if (t.subtasks) { t.subtasks.forEach((st) => { - if (st.status === 'done' || st.status === 'completed') { + if (isTaskComplete(st.status as TaskStatus)) { completedIds.add(`${t.id}.${st.id}`); } }); diff --git a/apps/cli/src/ui/components/dashboard.component.ts b/apps/cli/src/ui/components/dashboard.component.ts index 8b475ab8..ff5c5f86 100644 --- a/apps/cli/src/ui/components/dashboard.component.ts +++ b/apps/cli/src/ui/components/dashboard.component.ts @@ -3,9 +3,10 @@ * Displays project statistics and dependency information */ -import type { Task, TaskPriority } from '@tm/core'; +import type { Task, TaskPriority, TaskStatus } from '@tm/core'; import boxen from 'boxen'; import chalk from 'chalk'; +import { isTaskComplete } from '../../utils/task-status.js'; import { getComplexityWithColor } from '../../utils/ui.js'; /** @@ -21,6 +22,8 @@ export interface TaskStatistics { cancelled: number; review?: number; completionPercentage: number; + /** Count of all terminal complete tasks (done + completed + cancelled) */ + completedCount: number; } /** @@ -50,6 +53,7 @@ export interface NextTaskInfo { * Status breakdown for progress bars */ export interface StatusBreakdown { + done?: number; 'in-progress'?: number; pending?: number; blocked?: number; @@ -78,14 +82,17 @@ function createProgressBar( let bar = ''; let charsUsed = 0; - // 1. Green filled blocks for completed tasks (done) - const completedChars = Math.round((completionPercentage / 100) * width); - if (completedChars > 0) { - bar += chalk.green('█').repeat(completedChars); - charsUsed += completedChars; + // 1. Green filled blocks for done tasks only + // Note: completionPercentage includes cancelled, but we show them separately in the bar + if (statusBreakdown.done && statusBreakdown.done > 0) { + const doneChars = Math.round((statusBreakdown.done / 100) * width); + if (doneChars > 0) { + bar += chalk.green('█').repeat(doneChars); + charsUsed += doneChars; + } } - // 2. Gray filled blocks for cancelled (won't be done) + // 2. Gray filled blocks for cancelled (terminal complete, but visually distinct) if (statusBreakdown.cancelled && charsUsed < width) { const cancelledChars = Math.round( (statusBreakdown.cancelled / 100) * width @@ -170,7 +177,8 @@ export function calculateTaskStatistics(tasks: Task[]): TaskStatistics { deferred: 0, cancelled: 0, review: 0, - completionPercentage: 0 + completionPercentage: 0, + completedCount: 0 }; tasks.forEach((task) => { @@ -199,8 +207,12 @@ export function calculateTaskStatistics(tasks: Task[]): TaskStatistics { } }); + // Count terminal complete tasks for percentage calculation and display + stats.completedCount = tasks.filter((t) => isTaskComplete(t.status)).length; stats.completionPercentage = - stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; + stats.total > 0 + ? Math.round((stats.completedCount / stats.total) * 100) + : 0; return stats; } @@ -218,13 +230,16 @@ export function calculateSubtaskStatistics(tasks: Task[]): TaskStatistics { deferred: 0, cancelled: 0, review: 0, - completionPercentage: 0 + completionPercentage: 0, + completedCount: 0 }; + const allSubtasks: Array<{ status: string }> = []; tasks.forEach((task) => { if (task.subtasks && task.subtasks.length > 0) { task.subtasks.forEach((subtask) => { stats.total++; + allSubtasks.push(subtask); switch (subtask.status) { case 'done': stats.done++; @@ -252,8 +267,14 @@ export function calculateSubtaskStatistics(tasks: Task[]): TaskStatistics { } }); + // Count terminal complete subtasks for percentage calculation and display + stats.completedCount = allSubtasks.filter((st) => + isTaskComplete(st.status as TaskStatus) + ).length; stats.completionPercentage = - stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; + stats.total > 0 + ? Math.round((stats.completedCount / stats.total) * 100) + : 0; return stats; } @@ -264,18 +285,20 @@ export function calculateSubtaskStatistics(tasks: Task[]): TaskStatistics { export function calculateDependencyStatistics( tasks: Task[] ): DependencyStatistics { + // Get all terminal complete task IDs - these satisfy dependencies const completedTaskIds = new Set( - tasks.filter((t) => t.status === 'done').map((t) => t.id) + tasks.filter((t) => isTaskComplete(t.status)).map((t) => t.id) ); const tasksWithNoDeps = tasks.filter( (t) => - t.status !== 'done' && (!t.dependencies || t.dependencies.length === 0) + !isTaskComplete(t.status) && + (!t.dependencies || t.dependencies.length === 0) ).length; const tasksWithAllDepsSatisfied = tasks.filter( (t) => - t.status !== 'done' && + !isTaskComplete(t.status) && t.dependencies && t.dependencies.length > 0 && t.dependencies.every((depId) => completedTaskIds.has(depId)) @@ -283,7 +306,7 @@ export function calculateDependencyStatistics( const tasksBlockedByDeps = tasks.filter( (t) => - t.status !== 'done' && + !isTaskComplete(t.status) && t.dependencies && t.dependencies.length > 0 && !t.dependencies.every((depId) => completedTaskIds.has(depId)) @@ -356,6 +379,7 @@ function calculateStatusBreakdown(stats: TaskStatistics): StatusBreakdown { if (stats.total === 0) return {}; return { + done: (stats.done / stats.total) * 100, 'in-progress': (stats.inProgress / stats.total) * 100, pending: (stats.pending / stats.total) * 100, blocked: (stats.blocked / stats.total) * 100, @@ -378,7 +402,9 @@ function formatStatusLine( // Order: Done, Cancelled, Deferred, In Progress, Review, Pending, Blocked if (isSubtask) { - parts.push(`Completed: ${chalk.green(`${stats.done}/${stats.total}`)}`); + parts.push( + `Completed: ${chalk.green(`${stats.completedCount}/${stats.total}`)}` + ); } else { parts.push(`Done: ${chalk.green(stats.done)}`); } @@ -424,8 +450,8 @@ export function displayProjectDashboard( subtaskStatusBreakdown ); - const taskPercentage = `${taskStats.completionPercentage}% ${taskStats.done}/${taskStats.total}`; - const subtaskPercentage = `${subtaskStats.completionPercentage}% ${subtaskStats.done}/${subtaskStats.total}`; + const taskPercentage = `${taskStats.completionPercentage}% ${taskStats.completedCount}/${taskStats.total}`; + const subtaskPercentage = `${subtaskStats.completionPercentage}% ${subtaskStats.completedCount}/${subtaskStats.total}`; const content = chalk.white.bold('Project Dashboard') + diff --git a/apps/cli/src/utils/task-status.ts b/apps/cli/src/utils/task-status.ts new file mode 100644 index 00000000..4f1f8b67 --- /dev/null +++ b/apps/cli/src/utils/task-status.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Utilities for working with task statuses + * Re-exports status utilities from @tm/core for CLI convenience + */ + +// Re-export terminal status utilities from @tm/core (single source of truth) +export { + TERMINAL_COMPLETE_STATUSES, + isTaskComplete +} from '@tm/core'; diff --git a/apps/cli/tests/integration/commands/autopilot/workflow.test.ts b/apps/cli/tests/integration/commands/autopilot/workflow.test.ts index 3070b6ce..89ca9de8 100644 --- a/apps/cli/tests/integration/commands/autopilot/workflow.test.ts +++ b/apps/cli/tests/integration/commands/autopilot/workflow.test.ts @@ -2,8 +2,8 @@ * @fileoverview Integration tests for autopilot workflow commands */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { WorkflowState } from '@tm/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Track file system state in memory - must be in vi.hoisted() for mock access const { diff --git a/apps/cli/tests/unit/commands/autopilot/shared.test.ts b/apps/cli/tests/unit/commands/autopilot/shared.test.ts index 7e7f200a..e3fdcdfe 100644 --- a/apps/cli/tests/unit/commands/autopilot/shared.test.ts +++ b/apps/cli/tests/unit/commands/autopilot/shared.test.ts @@ -2,11 +2,11 @@ * @fileoverview Unit tests for autopilot shared utilities */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - validateTaskId, + OutputFormatter, parseSubtasks, - OutputFormatter + validateTaskId } from '../../../../src/commands/autopilot/shared.js'; // Mock fs-extra diff --git a/apps/cli/tests/unit/ui/dashboard.component.spec.ts b/apps/cli/tests/unit/ui/dashboard.component.spec.ts new file mode 100644 index 00000000..882dc3e1 --- /dev/null +++ b/apps/cli/tests/unit/ui/dashboard.component.spec.ts @@ -0,0 +1,287 @@ +/** + * @fileoverview Tests for dashboard component calculations + * Bug fix: Cancelled tasks should be treated as complete + */ + +import type { Task } from '@tm/core'; +import { describe, expect, it } from 'vitest'; +import { + type TaskStatistics, + calculateDependencyStatistics, + calculateSubtaskStatistics, + calculateTaskStatistics +} from '../../../src/ui/components/dashboard.component.js'; + +describe('dashboard.component - Bug Fix: Cancelled Tasks as Complete', () => { + describe('calculateTaskStatistics', () => { + it('should treat cancelled tasks as complete in percentage calculation', () => { + // Arrange: 14 done, 1 cancelled = 100% complete + const tasks: Task[] = [ + ...Array.from({ length: 14 }, (_, i) => ({ + id: i + 1, + title: `Task ${i + 1}`, + status: 'done' as const, + dependencies: [] + })), + { + id: 15, + title: 'Cancelled Task', + status: 'cancelled' as const, + dependencies: [] + } + ]; + + // Act + const stats = calculateTaskStatistics(tasks); + + // Assert + expect(stats.total).toBe(15); + expect(stats.done).toBe(14); + expect(stats.cancelled).toBe(1); + expect(stats.completedCount).toBe(15); // done + cancelled + // BUG: Current code shows 93% (14/15), should be 100% (15/15) + expect(stats.completionPercentage).toBe(100); + }); + + it('should treat completed status as complete in percentage calculation', () => { + // Arrange: Mix of done, completed, cancelled + const tasks: Task[] = [ + { + id: 1, + title: 'Done Task', + status: 'done' as const, + dependencies: [] + }, + { + id: 2, + title: 'Completed Task', + status: 'completed' as const, + dependencies: [] + }, + { + id: 3, + title: 'Cancelled Task', + status: 'cancelled' as const, + dependencies: [] + }, + { + id: 4, + title: 'Pending Task', + status: 'pending' as const, + dependencies: [] + } + ]; + + // Act + const stats = calculateTaskStatistics(tasks); + + // Assert + expect(stats.total).toBe(4); + expect(stats.done).toBe(1); + expect(stats.cancelled).toBe(1); + expect(stats.completedCount).toBe(3); // done + completed + cancelled + // 3 complete out of 4 total = 75% + expect(stats.completionPercentage).toBe(75); + }); + + it('should show 100% completion when all tasks are cancelled', () => { + // Arrange + const tasks: Task[] = [ + { + id: 1, + title: 'Cancelled 1', + status: 'cancelled' as const, + dependencies: [] + }, + { + id: 2, + title: 'Cancelled 2', + status: 'cancelled' as const, + dependencies: [] + } + ]; + + // Act + const stats = calculateTaskStatistics(tasks); + + // Assert + expect(stats.total).toBe(2); + expect(stats.cancelled).toBe(2); + expect(stats.completedCount).toBe(2); // All cancelled = all complete + // BUG: Current code shows 0%, should be 100% + expect(stats.completionPercentage).toBe(100); + }); + + it('should show 0% completion when no tasks are complete', () => { + // Arrange + const tasks: Task[] = [ + { + id: 1, + title: 'Pending Task', + status: 'pending' as const, + dependencies: [] + }, + { + id: 2, + title: 'In Progress Task', + status: 'in-progress' as const, + dependencies: [] + } + ]; + + // Act + const stats = calculateTaskStatistics(tasks); + + // Assert + expect(stats.completionPercentage).toBe(0); + }); + }); + + describe('calculateSubtaskStatistics', () => { + it('should treat cancelled subtasks as complete in percentage calculation', () => { + // Arrange: Task with 3 done subtasks and 1 cancelled = 100% + const tasks: Task[] = [ + { + id: 1, + title: 'Parent Task', + status: 'in-progress' as const, + dependencies: [], + subtasks: [ + { id: '1', title: 'Sub 1', status: 'done' }, + { id: '2', title: 'Sub 2', status: 'done' }, + { id: '3', title: 'Sub 3', status: 'done' }, + { id: '4', title: 'Sub 4', status: 'cancelled' } + ] + } + ]; + + // Act + const stats = calculateSubtaskStatistics(tasks); + + // Assert + expect(stats.total).toBe(4); + expect(stats.done).toBe(3); + expect(stats.cancelled).toBe(1); + expect(stats.completedCount).toBe(4); // done + cancelled + // BUG: Current code shows 75% (3/4), should be 100% (4/4) + expect(stats.completionPercentage).toBe(100); + }); + + it('should handle completed status in subtasks', () => { + // Arrange + const tasks: Task[] = [ + { + id: 1, + title: 'Parent Task', + status: 'in-progress' as const, + dependencies: [], + subtasks: [ + { id: '1', title: 'Sub 1', status: 'done' }, + { id: '2', title: 'Sub 2', status: 'completed' }, + { id: '3', title: 'Sub 3', status: 'pending' } + ] + } + ]; + + // Act + const stats = calculateSubtaskStatistics(tasks); + + // Assert + expect(stats.total).toBe(3); + expect(stats.completedCount).toBe(2); // done + completed + // 2 complete (done + completed) out of 3 = 67% + expect(stats.completionPercentage).toBe(67); + }); + }); + + describe('calculateDependencyStatistics', () => { + it('should treat cancelled tasks as satisfied dependencies', () => { + // Arrange: Task 15 depends on cancelled task 14 + const tasks: Task[] = [ + ...Array.from({ length: 13 }, (_, i) => ({ + id: i + 1, + title: `Task ${i + 1}`, + status: 'done' as const, + dependencies: [] + })), + { + id: 14, + title: 'Cancelled Dependency', + status: 'cancelled' as const, + dependencies: [] + }, + { + id: 15, + title: 'Dependent Task', + status: 'pending' as const, + dependencies: [14] + } + ]; + + // Act + const stats = calculateDependencyStatistics(tasks); + + // Assert + // Task 15 should be ready to work on since its dependency (14) is cancelled + // BUG: Current code shows task 15 as blocked, should show as ready + expect(stats.tasksBlockedByDeps).toBe(0); + expect(stats.tasksReadyToWork).toBeGreaterThan(0); + }); + + it('should treat completed status as satisfied dependencies', () => { + // Arrange + const tasks: Task[] = [ + { + id: 1, + title: 'Completed Dependency', + status: 'completed' as const, + dependencies: [] + }, + { + id: 2, + title: 'Dependent Task', + status: 'pending' as const, + dependencies: [1] + } + ]; + + // Act + const stats = calculateDependencyStatistics(tasks); + + // Assert + expect(stats.tasksBlockedByDeps).toBe(0); + expect(stats.tasksReadyToWork).toBe(1); + }); + + it('should count tasks with cancelled dependencies as ready', () => { + // Arrange: Multiple tasks depending on cancelled tasks + const tasks: Task[] = [ + { + id: 1, + title: 'Cancelled Task', + status: 'cancelled' as const, + dependencies: [] + }, + { + id: 2, + title: 'Dependent 1', + status: 'pending' as const, + dependencies: [1] + }, + { + id: 3, + title: 'Dependent 2', + status: 'pending' as const, + dependencies: [1] + } + ]; + + // Act + const stats = calculateDependencyStatistics(tasks); + + // Assert + expect(stats.tasksBlockedByDeps).toBe(0); + expect(stats.tasksReadyToWork).toBe(2); // Both dependents should be ready + }); + }); +}); diff --git a/packages/tm-core/src/common/constants/index.ts b/packages/tm-core/src/common/constants/index.ts index aa976fb6..f319267a 100644 --- a/packages/tm-core/src/common/constants/index.ts +++ b/packages/tm-core/src/common/constants/index.ts @@ -36,6 +36,39 @@ export const TASK_STATUSES: readonly TaskStatus[] = [ 'review' ] as const; +/** + * Terminal complete statuses - tasks that are finished and satisfy dependencies + * These statuses indicate a task is in a final state and: + * - Should count toward completion percentage + * - Should be considered satisfied for dependency resolution + * - Should not be selected as "next task" + * + * Note: 'completed' is a workflow-specific alias for 'done' used in some contexts + */ +export const TERMINAL_COMPLETE_STATUSES: readonly TaskStatus[] = [ + 'done', + 'completed', + 'cancelled' +] as const; + +/** + * Check if a task status represents a terminal complete state + * + * @param status - The task status to check + * @returns true if the status represents a completed/terminal task + * + * @example + * ```typescript + * isTaskComplete('done') // true + * isTaskComplete('completed') // true + * isTaskComplete('cancelled') // true + * isTaskComplete('pending') // false + * ``` + */ +export function isTaskComplete(status: TaskStatus): boolean { + return TERMINAL_COMPLETE_STATUSES.includes(status); +} + /** * Valid task priority values */ diff --git a/packages/tm-core/src/modules/tasks/services/task-loader.service.ts b/packages/tm-core/src/modules/tasks/services/task-loader.service.ts index 9c3986f3..f64b155e 100644 --- a/packages/tm-core/src/modules/tasks/services/task-loader.service.ts +++ b/packages/tm-core/src/modules/tasks/services/task-loader.service.ts @@ -3,9 +3,10 @@ * Loads and validates tasks for autopilot execution */ -import type { Task, Subtask, TaskStatus } from '../../../common/types/index.js'; +import type { Task, Subtask } from '../../../common/types/index.js'; import type { TaskService } from './task-service.js'; import { getLogger } from '../../../common/logger/factory.js'; +import { isTaskComplete } from '../../../common/constants/index.js'; const logger = getLogger('TaskLoader'); @@ -131,9 +132,7 @@ export class TaskLoaderService { * Validate task status is appropriate for autopilot */ private validateTaskStatus(task: Task): TaskValidationResult { - const completedStatuses: TaskStatus[] = ['done', 'completed', 'cancelled']; - - if (completedStatuses.includes(task.status)) { + if (isTaskComplete(task.status)) { return { success: false, errorType: 'task_completed',