mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
fix: adopt modifyJson pattern for atomic read-modify-write operations (#1569)
Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Resolves issue #1568.
This commit is contained in:
9
.changeset/adopt-modifyjson-pattern.md
Normal file
9
.changeset/adopt-modifyjson-pattern.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"task-master-ai": patch
|
||||
---
|
||||
|
||||
Improve concurrency safety by adopting modifyJson pattern in file-storage
|
||||
|
||||
- Refactor saveTasks, createTag, deleteTag, renameTag to use modifyJson for atomic read-modify-write operations
|
||||
- This prevents lost updates when multiple processes concurrently modify tasks.json
|
||||
- Complements the cross-process file locking added in PR #1566
|
||||
@@ -220,6 +220,7 @@ export class FileStorage implements IStorage {
|
||||
|
||||
/**
|
||||
* Save tasks for a specific tag in the single tasks.json file
|
||||
* Uses modifyJson for atomic read-modify-write to prevent lost updates
|
||||
*/
|
||||
async saveTasks(tasks: Task[], tag?: string): Promise<void> {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
@@ -228,17 +229,6 @@ export class FileStorage implements IStorage {
|
||||
// Ensure directory exists
|
||||
await this.fileOps.ensureDir(this.pathResolver.getTasksDir());
|
||||
|
||||
// Get existing data from the file
|
||||
let existingData: any = {};
|
||||
try {
|
||||
existingData = await this.fileOps.readJson(filePath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw new Error(`Failed to read existing tasks: ${error.message}`);
|
||||
}
|
||||
// File doesn't exist, start with empty data
|
||||
}
|
||||
|
||||
// Create metadata for this tag
|
||||
const metadata: TaskMetadata = {
|
||||
version: '1.0.0',
|
||||
@@ -251,6 +241,8 @@ export class FileStorage implements IStorage {
|
||||
// Normalize tasks
|
||||
const normalizedTasks = this.normalizeTaskIds(tasks);
|
||||
|
||||
// Use modifyJson for atomic read-modify-write
|
||||
await this.fileOps.modifyJson(filePath, (existingData: any) => {
|
||||
// Update the specific tag in the existing data structure
|
||||
if (
|
||||
this.formatHandler.detectFormat(existingData) === 'legacy' ||
|
||||
@@ -263,9 +255,10 @@ export class FileStorage implements IStorage {
|
||||
tasks: normalizedTasks,
|
||||
metadata
|
||||
};
|
||||
return existingData;
|
||||
} else if (resolvedTag === 'master') {
|
||||
// Standard format for master tag
|
||||
existingData = {
|
||||
return {
|
||||
tasks: normalizedTasks,
|
||||
metadata
|
||||
};
|
||||
@@ -274,7 +267,7 @@ export class FileStorage implements IStorage {
|
||||
const masterTasks = existingData.tasks || [];
|
||||
const masterMetadata = existingData.metadata || metadata;
|
||||
|
||||
existingData = {
|
||||
return {
|
||||
master: {
|
||||
tasks: masterTasks,
|
||||
metadata: masterMetadata
|
||||
@@ -285,9 +278,7 @@ export class FileStorage implements IStorage {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Write the updated file
|
||||
await this.fileOps.writeJson(filePath, existingData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -594,6 +585,7 @@ export class FileStorage implements IStorage {
|
||||
|
||||
/**
|
||||
* Create a new tag in the tasks.json file
|
||||
* Uses modifyJson for atomic read-modify-write to prevent lost updates
|
||||
*/
|
||||
async createTag(
|
||||
tagName: string,
|
||||
@@ -602,7 +594,7 @@ export class FileStorage implements IStorage {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
|
||||
try {
|
||||
const existingData = await this.fileOps.readJson(filePath);
|
||||
await this.fileOps.modifyJson(filePath, (existingData: any) => {
|
||||
const format = this.formatHandler.detectFormat(existingData);
|
||||
|
||||
if (format === 'legacy') {
|
||||
@@ -615,7 +607,7 @@ export class FileStorage implements IStorage {
|
||||
}
|
||||
|
||||
// Get tasks to copy if specified
|
||||
let tasksToCopy = [];
|
||||
let tasksToCopy: any[] = [];
|
||||
if (options?.copyFrom) {
|
||||
if (
|
||||
options.copyFrom in existingData &&
|
||||
@@ -640,19 +632,19 @@ export class FileStorage implements IStorage {
|
||||
}
|
||||
};
|
||||
|
||||
await this.fileOps.writeJson(filePath, existingData);
|
||||
return existingData;
|
||||
} else {
|
||||
// Standard format - need to convert to legacy format first
|
||||
const masterTasks = existingData.tasks || [];
|
||||
const masterMetadata = existingData.metadata || {};
|
||||
|
||||
// Get tasks to copy (from master in this case)
|
||||
let tasksToCopy = [];
|
||||
let tasksToCopy: any[] = [];
|
||||
if (options?.copyFrom === 'master' || !options?.copyFrom) {
|
||||
tasksToCopy = JSON.parse(JSON.stringify(masterTasks));
|
||||
}
|
||||
|
||||
const newData = {
|
||||
return {
|
||||
master: {
|
||||
tasks: masterTasks,
|
||||
metadata: { ...masterMetadata, tags: ['master'] }
|
||||
@@ -669,9 +661,8 @@ export class FileStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await this.fileOps.writeJson(filePath, newData);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error('Tasks file not found - initialize project first');
|
||||
@@ -682,27 +673,42 @@ export class FileStorage implements IStorage {
|
||||
|
||||
/**
|
||||
* Delete a tag from the single tasks.json file
|
||||
* Uses modifyJson for atomic read-modify-write to prevent lost updates
|
||||
*/
|
||||
async deleteTag(tag: string): Promise<void> {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
|
||||
try {
|
||||
const existingData = await this.fileOps.readJson(filePath);
|
||||
// Use modifyJson to handle all cases atomically
|
||||
let shouldDeleteFile = false;
|
||||
|
||||
if (this.formatHandler.detectFormat(existingData) === 'legacy') {
|
||||
await this.fileOps.modifyJson(filePath, (data: any) => {
|
||||
if (
|
||||
this.formatHandler.detectFormat(data) !== 'legacy' &&
|
||||
tag === 'master'
|
||||
) {
|
||||
// Standard format - mark for file deletion after lock release
|
||||
shouldDeleteFile = true;
|
||||
return data; // Return unchanged, we'll delete the file after
|
||||
}
|
||||
|
||||
if (this.formatHandler.detectFormat(data) === 'legacy') {
|
||||
// Legacy format - remove the tag key
|
||||
if (tag in existingData) {
|
||||
delete existingData[tag];
|
||||
await this.fileOps.writeJson(filePath, existingData);
|
||||
if (tag in data) {
|
||||
delete data[tag];
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(`Tag ${tag} not found`);
|
||||
}
|
||||
} else if (tag === 'master') {
|
||||
// Standard format - delete the entire file for master tag
|
||||
await this.fileOps.deleteFile(filePath);
|
||||
} else {
|
||||
throw new Error(`Tag ${tag} not found in standard format`);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete the file if we're removing master tag from standard format
|
||||
if (shouldDeleteFile) {
|
||||
await this.fileOps.deleteFile(filePath);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`Tag ${tag} not found - file doesn't exist`);
|
||||
@@ -713,13 +719,13 @@ export class FileStorage implements IStorage {
|
||||
|
||||
/**
|
||||
* Rename a tag within the single tasks.json file
|
||||
* Uses modifyJson for atomic read-modify-write to prevent lost updates
|
||||
*/
|
||||
async renameTag(oldTag: string, newTag: string): Promise<void> {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
|
||||
try {
|
||||
const existingData = await this.fileOps.readJson(filePath);
|
||||
|
||||
await this.fileOps.modifyJson(filePath, (existingData: any) => {
|
||||
if (this.formatHandler.detectFormat(existingData) === 'legacy') {
|
||||
// Legacy format - rename the tag key
|
||||
if (oldTag in existingData) {
|
||||
@@ -731,7 +737,7 @@ export class FileStorage implements IStorage {
|
||||
existingData[newTag].metadata.tags = [newTag];
|
||||
}
|
||||
|
||||
await this.fileOps.writeJson(filePath, existingData);
|
||||
return existingData;
|
||||
} else {
|
||||
throw new Error(`Tag ${oldTag} not found`);
|
||||
}
|
||||
@@ -740,17 +746,16 @@ export class FileStorage implements IStorage {
|
||||
const masterTasks = existingData.tasks || [];
|
||||
const masterMetadata = existingData.metadata || {};
|
||||
|
||||
const newData = {
|
||||
return {
|
||||
[newTag]: {
|
||||
tasks: masterTasks,
|
||||
metadata: { ...masterMetadata, tags: [newTag] }
|
||||
}
|
||||
};
|
||||
|
||||
await this.fileOps.writeJson(filePath, newData);
|
||||
} else {
|
||||
throw new Error(`Tag ${oldTag} not found in standard format`);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`Tag ${oldTag} not found - file doesn't exist`);
|
||||
|
||||
@@ -3,18 +3,11 @@
|
||||
* Verifies that concurrent access to tasks.json is properly serialized
|
||||
*/
|
||||
|
||||
import {
|
||||
jest,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach
|
||||
} from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
Reference in New Issue
Block a user