Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-task-id-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"task-master-ai": patch
---

Fix inconsistent task ID serialization in tasks.json (#1583)

Task IDs were inconsistently saved as strings or numbers depending on which command was used (e.g., `set-status` vs `update-task`), causing unnecessary git diffs. The tm-core file storage adapter was incorrectly converting IDs to strings when file storage should use numbers. Task and subtask IDs are now consistently saved as numbers in tasks.json.
10 changes: 6 additions & 4 deletions apps/cli/src/commands/start.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class StartCommand extends Command {

// Get the task ID from argument or option, or find next available task
const idArg = taskId || options.id || null;
let targetTaskId = idArg;
let targetTaskId: string | number | null = idArg;

if (!targetTaskId) {
spinner = ora('Finding next available task...').start();
Expand Down Expand Up @@ -196,7 +196,7 @@ export class StartCommand extends Command {
/**
* Get the next available task using tm-core
*/
private async performGetNextTask(): Promise<string | null> {
private async performGetNextTask(): Promise<string | number | null> {
if (!this.tmCore) {
throw new Error('TmCore not initialized');
}
Expand All @@ -206,7 +206,9 @@ export class StartCommand extends Command {
/**
* Show pre-launch message using tm-core data
*/
private async showPreLaunchMessage(targetTaskId: string): Promise<void> {
private async showPreLaunchMessage(
targetTaskId: string | number
): Promise<void> {
if (!this.tmCore) return;

const { task, isSubtask } = await this.tmCore.tasks.get(targetTaskId);
Expand All @@ -227,7 +229,7 @@ export class StartCommand extends Command {
* Perform start task using tm-core business logic
*/
private async performStartTask(
targetTaskId: string,
targetTaskId: string | number,
options: StartCommandOptions
): Promise<CoreStartTaskResult> {
if (!this.tmCore) {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/ui/components/task-detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function displaySubtasks(
title: string;
status: any;
description?: string;
dependencies?: string[];
dependencies?: (string | number)[];
}>,
parentTaskId?: string | number,
storageType?: Exclude<StorageType, 'auto'>
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions packages/tm-core/src/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export interface TaskImplementationMetadata {
* Placeholder task interface for temporary/minimal task objects
*/
export interface PlaceholderTask {
id: string;
id: number | string;
title: string;
status: TaskStatus;
priority: TaskPriority;
Expand All @@ -129,12 +129,12 @@ export interface PlaceholderTask {
* Base task interface
*/
export interface Task extends TaskImplementationMetadata {
id: string;
id: number | string;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
dependencies: string[];
dependencies: (number | string)[];
details: string;
testStrategy: string;
subtasks: Subtask[];
Expand Down Expand Up @@ -171,7 +171,7 @@ export interface Task extends TaskImplementationMetadata {
*/
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
id: number | string;
parentId: string;
parentId: number | string;
subtasks?: never; // Subtasks cannot have their own subtasks
}

Expand Down Expand Up @@ -203,7 +203,7 @@ export interface TaskCollection {
*/
export interface TaskTag {
name: string;
tasks: string[]; // Task IDs belonging to this tag
tasks: (number | string)[]; // Task IDs belonging to this tag
metadata: Record<string, any>;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/tm-core/src/common/types/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
*/

/**
* @deprecated Use string directly instead. This will be removed in a future version.
* @deprecated Use number | string directly instead. This will be removed in a future version.
*/
export type TaskId = string;
export type TaskId = number | string;
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@ export class ClaudeExecutor extends BaseExecutor {
async execute(task: Task): Promise<ExecutionResult> {
const startTime = new Date().toISOString();

const taskIdStr = String(task.id);
try {
// Check if Claude is available
const isAvailable = await this.isAvailable();
if (!isAvailable) {
return this.createResult(
task.id,
taskIdStr,
false,
undefined,
`Claude CLI not found. Please ensure 'claude' command is available in PATH.`
Expand All @@ -66,17 +67,17 @@ export class ClaudeExecutor extends BaseExecutor {
const fullPrompt = `${this.claudeConfig.systemPrompt}\n\nHere is the task to complete:\n\n${taskPrompt}`;

// Execute Claude with the task details
const result = await this.runClaude(fullPrompt, task.id);
const result = await this.runClaude(fullPrompt, taskIdStr);

return {
...result,
startTime,
endTime: new Date().toISOString()
};
} catch (error: any) {
this.logger.error(`Failed to execute task ${task.id}:`, error);
this.logger.error(`Failed to execute task ${taskIdStr}:`, error);
return this.createResult(
task.id,
taskIdStr,
false,
undefined,
error.message || 'Unknown error occurred'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class ExecutorService {
task: Task,
executorType?: ExecutorType
): Promise<ExecutionResult> {
const taskIdStr = String(task.id);
try {
// Determine executor type
const type =
Expand All @@ -47,7 +48,7 @@ export class ExecutorService {
if (!type) {
return {
success: false,
taskId: task.id,
taskId: taskIdStr,
executorType: 'claude',
error:
'No executor available. Please install Claude CLI or specify an executor type.',
Expand All @@ -69,23 +70,23 @@ export class ExecutorService {
if (!isAvailable) {
return {
success: false,
taskId: task.id,
taskId: taskIdStr,
executorType: type,
error: `Executor ${type} is not available or not configured properly`,
startTime: new Date().toISOString()
};
}

// Execute the task
this.logger.info(`Starting task ${task.id} with ${type} executor`);
this.logger.info(`Starting task ${taskIdStr} with ${type} executor`);
const result = await this.currentExecutor.execute(task);

return result;
} catch (error: any) {
this.logger.error(`Failed to execute task ${task.id}:`, error);
this.logger.error(`Failed to execute task ${taskIdStr}:`, error);
return {
success: false,
taskId: task.id,
taskId: taskIdStr,
executorType: executorType || 'claude',
error: error.message || 'Unknown error occurred',
startTime: new Date().toISOString()
Expand Down
9 changes: 6 additions & 3 deletions packages/tm-core/src/modules/storage/adapters/api-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,15 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();

try {
// Convert task ID to string for API storage
const taskIdStr = String(task.id);

// Check if task exists
const existing = await this.repository.getTask(this.projectId, task.id);
const existing = await this.repository.getTask(this.projectId, taskIdStr);

if (existing) {
await this.retryOperation(() =>
this.repository.updateTask(this.projectId, task.id, task)
this.repository.updateTask(this.projectId, taskIdStr, task)
);
} else {
await this.retryOperation(() =>
Expand Down Expand Up @@ -914,7 +917,7 @@ export class ApiStorage implements IStorage {
if (tasks.length > 0) {
await this.repository.bulkDeleteTasks(
this.projectId,
tasks.map((t) => t.id)
tasks.map((t) => String(t.id))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,19 +282,31 @@ export class FileStorage implements IStorage {
}

/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
* Note: Uses spread operator to preserve all task properties including user-defined metadata
* Normalize task IDs - keep Task IDs as numbers, Subtask IDs as numbers
* Dependencies can be either numbers (task refs) or strings (subtask refs like "7.1")
*/
private normalizeTaskIds(tasks: Task[]): Task[] {
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
id: Number(task.id),
// Dependencies: keep as string if it contains "." (subtask ref), otherwise convert to number
dependencies:
task.dependencies?.map((dep) => {
const depStr = String(dep);
return depStr.includes('.') ? depStr : Number(dep);
}) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
id: Number(subtask.id),
// Set parentId to the parent task's ID (subtasks are nested, so we know the parent)
parentId: Number(task.id),
// Subtask dependencies: keep as string if it contains "." (subtask ref), otherwise convert to number
dependencies:
subtask.dependencies?.map((dep) => {
const depStr = String(dep);
return depStr.includes('.') ? depStr : Number(dep);
}) || []
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
})) || []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,18 +217,31 @@ export class FormatHandler {
}

/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
* Normalize task IDs - keep Task IDs as numbers, Subtask IDs as numbers
* Dependencies can be either numbers (task refs) or strings (subtask refs like "7.1")
*/
private normalizeTasks(tasks: Task[]): Task[] {
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
id: Number(task.id), // Task IDs are numbers
// Dependencies: keep as string if it contains "." (subtask ref), otherwise convert to number
dependencies:
task.dependencies?.map((dep) => {
const depStr = String(dep);
return depStr.includes('.') ? depStr : Number(dep);
}) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
// Set parentId to the parent task's ID (subtasks are nested, so we know the parent)
parentId: Number(task.id),
// Subtask dependencies: keep as string if it contains "." (subtask ref), otherwise convert to number
dependencies:
subtask.dependencies?.map((dep) => {
const depStr = String(dep);
return depStr.includes('.') ? depStr : Number(dep);
}) || []
})) || []
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export class TaskExecutionService {
/**
* Get the next available task to start
*/
async getNextAvailableTask(): Promise<string | null> {
async getNextAvailableTask(): Promise<string | number | null> {
const nextTask = await this.taskService.getNextTask();
return nextTask?.id || null;
}
Expand Down
16 changes: 9 additions & 7 deletions packages/tm-core/src/modules/tasks/tasks-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,17 @@ export class TasksDomain {
* @returns Discriminated union indicating task/subtask with proper typing
*/
async get(
taskId: string,
taskId: string | number,
tag?: string
): Promise<
| { task: Task; isSubtask: false }
| { task: Subtask; isSubtask: true }
| { task: null; isSubtask: boolean }
> {
// Convert to string for parsing (handles both numeric IDs and string subtask refs like "1.2")
const taskIdStr = String(taskId);
// Parse ID - check for dot notation (subtask)
const parts = taskId.split('.');
const parts = taskIdStr.split('.');
const parentId = parts[0];
const subtaskIdPart = parts[1];

Expand Down Expand Up @@ -292,21 +294,21 @@ export class TasksDomain {
/**
* Start working on a task
*/
async start(taskId: string, options?: StartTaskOptions): Promise<StartTaskResult> {
return this.executionService.startTask(taskId, options);
async start(taskId: string | number, options?: StartTaskOptions): Promise<StartTaskResult> {
return this.executionService.startTask(String(taskId), options);
}

/**
* Check for in-progress conflicts
*/
async checkInProgressConflicts(taskId: string) {
return this.executionService.checkInProgressConflicts(taskId);
async checkInProgressConflicts(taskId: string | number) {
return this.executionService.checkInProgressConflicts(String(taskId));
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

/**
* Get next available task (from execution service)
*/
async getNextAvailable(): Promise<string | null> {
async getNextAvailable(): Promise<string | number | null> {
return this.executionService.getNextAvailableTask();
}

Expand Down
Loading